carlos / platform Public

Merge pull request #164 from carlosframework/worktree-route-hostnames

route hostnames: attach/detach an extra public hostname on an existing route (#151)
Paul Campbell 8f3637758fc2d975671d622cb6620fe57fdae4b3 parent e03d95bparent 3950833
26 files changed, +4068 −32
  • apimode.go +86 −0
  • cmd/console/main.go +20 −0
  • docs/superpowers/plans/2026-08-12-route-extra-hostnames.md +548 −0
  • docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md +288 −0
  • domains_cli.go +150 −0
  • domains_cli_test.go +393 −0
  • internal/console/apidomains.go +335 −0
  • internal/console/apidomains_test.go +480 −0
  • internal/console/authcli.go +9 −2
  • internal/console/server.go +9 −0
  • internal/domains/aliases.go +74 −0
  • internal/domains/aliases_test.go +245 −0
  • internal/domains/claims.go +251 −0
  • internal/domains/claims_test.go +256 −0
  • internal/domains/reconcile.go +43 −10
  • internal/domains/reconcile_test.go +77 −0
  • internal/edge/alias_test.go +277 −0
  • internal/edge/edge.go +35 −1
  • internal/edge/storedoor.go +43 −7
  • internal/edge/storedoor_test.go +20 −0
  • internal/host/agent.go +21 −3
  • internal/registry/registry.go +121 −3
  • internal/registry/registry_test.go +186 −0
  • internal/status/snapshot.go +59 −6
  • internal/status/status_test.go +38 −0
  • main.go +4 −0
diff --git a/apimode.go b/apimode.go
index 3dffd2b6e..53fa244c0 100644
--- a/apimode.go
+++ b/apimode.go
@@ -1685,6 +1685,92 @@ func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host st
return nil
}
+// apiDomainRecord mirrors the domains door's per-row wire shape
+// (domainListItem, internal/console/apidomains.go) — DNSStatus and
+// CertExpiresAt are the box-stamped reading columns and stay their zero
+// value ("" / 0) for a claim no live box has reported on yet ("pending"),
+// the same absent-stays-absent convention status.AliasReading uses.
+type apiDomainRecord struct {
+ Host string `json:"host"`
+ Route string `json:"route"`
+ Status string `json:"status"`
+ DNSStatus string `json:"dns_status,omitempty"`
+ CertExpiresAt int64 `json:"cert_expires_at,omitempty"`
+}
+
+// domainAttach asks the console to claim host for app, pointed at route (a
+// route hostname, or "" to let the console default it to the app's single
+// instance record). created is the caller's own answer — false on a re-
+// attach that changed nothing or only repointed the route — and
+// resolvedRoute is always the route the claim actually carries, whatever
+// this call sent: a caller that omitted -route learns what the server
+// defaulted it to. A 409 (another account or app already holds the
+// hostname) surfaces as "hostname unavailable" verbatim, apiErrorFromBody's
+// json-first decode doing the work — the door never says who holds it.
+func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route string) (created bool, resolvedRoute string, err error) {
+ in := struct {
+ Host string `json:"host"`
+ Route string `json:"route,omitempty"`
+ }{host, route}
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/domains"), in)
+ if err != nil {
+ return false, "", err
+ }
+ if status != http.StatusOK {
+ return false, "", apiErrorFromBody(status, body)
+ }
+ var out struct {
+ Host string `json:"host"`
+ Route string `json:"route"`
+ Created bool `json:"created"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return false, "", fmt.Errorf("domains attach: unreadable response: %w", err)
+ }
+ return out.Created, out.Route, nil
+}
+
+// domainDetach releases a hostname claim. Always 200 on a live console
+// (handleAPIDomainDetach's own contract): removed is false for a hostname
+// this app never held, one it no longer holds, or one another account
+// holds — the door's own quiet-in-both-directions neutrality (spec §5), not
+// distinguished here either.
+func (s *apiSession) domainDetach(ctx context.Context, a apiAcct, app, host string) (removed bool, err error) {
+ status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/domains/"+url.PathEscape(host)))
+ if err != nil {
+ return false, err
+ }
+ if status != http.StatusOK {
+ return false, apiErrorFromBody(status, body)
+ }
+ var out struct {
+ Removed bool `json:"removed"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return false, fmt.Errorf("domains detach: unreadable response: %w", err)
+ }
+ return out.Removed, nil
+}
+
+// domainList reads the app's hostname claims, each joined to the fleet's
+// readings sweep for status/dns_status/cert_expires_at.
+func (s *apiSession) domainList(ctx context.Context, a apiAcct, app string) ([]apiDomainRecord, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/domains"))
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Domains []apiDomainRecord `json:"domains"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("domains list: unreadable response: %w", err)
+ }
+ return res.Domains, nil
+}
+
// shipTimeout bounds ship's own context — generous on purpose. The console
// gives itself up to 10 minutes to read the request body and 2 minutes to
// store it (internal/console/apicli.go), well past the 30s absolute Timeout
diff --git a/cmd/console/main.go b/cmd/console/main.go
index 0de48d4a1..6d22a8ecb 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -247,6 +247,7 @@ func serve() {
SecretsPub: secretsPub(),
AppsDomain: appsDomain(),
RedirectHosts: redirectHosts(),
+ DenyDomains: denyDomains(),
StoreProvisioner: storeProvisioner,
StoreDeployment: storeDeployment,
StoreOperators: storeOperators(),
@@ -449,6 +450,25 @@ func redirectHosts() []string {
return out
}
+// denyDomains parses CONSOLE_DENY_DOMAINS (comma-separated) — hostnames, and
+// everything under them, that `carlos domains attach` refuses (issue #151).
+// This is the operator's list of names the console cannot derive on its own:
+// the edge's reserved tunnel hostname, retired console origins, any other
+// platform-managed domain. The console's own origin host and
+// PLATFORM_APPS_DOMAIN are added by the door itself (deniedDomains,
+// internal/console/apidomains.go), so an unset variable is a legal — and
+// still safe — deployment. Blank entries are dropped: an empty string in the
+// list would be a deny entry no hostname could avoid matching.
+func denyDomains() []string {
+ var out []string
+ for _, d := range strings.Split(os.Getenv("CONSOLE_DENY_DOMAINS"), ",") {
+ if d = strings.ToLower(strings.TrimSpace(d)); d != "" {
+ out = append(out, d)
+ }
+ }
+ return out
+}
+
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
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..8cada5ce5
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-12-route-extra-hostnames.md
@@ -0,0 +1,548 @@
+# 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.
diff --git a/docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md b/docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md
new file mode 100644
index 000000000..e6cb277d8
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md
@@ -0,0 +1,288 @@
+# Route extra hostnames (attach/detach) — design record
+
+Date: 2026-08-12. Issue: #151 (titogo custom domains, the CARLOS edge half —
+titogo repo `docs/superpowers/specs/2026-08-11-custom-domains-design.md` §4).
+Approved by Paul in the brainstorming session that produced this document.
+
+Companion division of labor (agreed cross-session, recorded in §10): #151
+lands the whole alias mechanism, API door, and CLI; #82 later rides the same
+door for console UI, deploy auto-attach, and the first-route case.
+
+## What this is
+
+Attach an extra public hostname to an existing route: the edge obtains and
+renews a certificate for it (HTTP-01, each customer domain its own Let's
+Encrypt registered domain, so rate limits are per-customer), proxies it to
+the same backend, and wakes hibernated instances identically to the route's
+primary hostname. Detach removes it. Both verbs are idempotent — the titogo
+router retries them on its reload passes.
+
+Surface: a first-class member feature — console API door + `carlos domains`
+CLI verb — which the titogo router also calls with its app token. Not a
+titogo-only side door.
+
+## Decisions made in the session
+
+- **Member feature + CLI**, not a minimal titogo-shaped door.
+- **Ownership proof = DNS pointing at the edge.** Attach validates shape,
+ uniqueness, and the platform-domain denylist; the hostname only certs and
+ serves when its DNS actually resolves to us (HTTP-01 succeeding is the
+ gate). No platform TXT challenge in v1 — titogo's richer claim
+ verification stays titogo's.
+- **Alias table + edge map, not a second routes row.** The #82 sketch
+ (mint a full copied route row) reproduces the #129 socket-race class for
+ hibernating instances, the #148 bare-alias breakage for unit-backed
+ routes, and copy-drift when sockets move. Titogo's registry made the same
+ call for the same reasons. The #82 session has conceded its second-row
+ materialization to this mechanism (§10).
+- **Build on main, not the #137 branch.** The alias record is a sibling of
+ `instances/<host>.json`, not a field inside it — #137's spec (§9)
+ explicitly scoped this territory out. #137 is a design precedent here
+ (record → reconcile → derived state), not a code dependency.
+
+## Architecture
+
+### 1. Desired state: one global claim record per hostname
+
+Bucket key `domains/<host>.json`:
+
+```json
+{
+ "schema": 1,
+ "host": "tickets.example.com",
+ "account": "<sqid>",
+ "app": "tito",
+ "route": "<primary-route-host>",
+ "created_at": "...",
+ "created_by": "<address or app-token label>"
+}
+```
+
+The key is global, so create-only CAS **is** the cross-account uniqueness
+check — no racy list-then-verify. `route` names the primary route this
+hostname aliases. An empty/omitted `route` is reserved (additively) for
+#82's first-route case: "this host is, or will be, its own primary route."
+The #151 reconcile pass ignores routeless records entirely.
+
+Wire-shape rules apply from day one: additive only; anything written before
+a change must still parse.
+
+Attach = CAS create (or converge `route` if the same owner re-attaches);
+detach = owner-verified delete. The claim-writing helper is exported so
+#82's first-route path writes claims through the same code.
+
+### 2. Registry: a wholly derived `route_aliases` table
+
+```
+route_aliases(
+ alias TEXT PRIMARY KEY,
+ host TEXT NOT NULL, -- the primary route
+ account TEXT NOT NULL,
+ app TEXT NOT NULL,
+ dns_status TEXT,
+ dns_checked_at TEXT,
+ cert_expires_at TEXT,
+ created_at TEXT
+)
+```
+
+Idempotent CREATE in `Registry.migrate()`. Every row is derived from a claim
+record by the reconcile pass — nothing else writes the table, so no
+`Provisioned`-style ownership flag is needed: the pass owns all rows.
+
+`Fingerprint()` grows a **separate second query/hash section** for
+`route_aliases` — the existing routes SELECT line is not edited. (Agreed
+with the HA session so #152's `addrs`/`health` column additions to that
+line cannot collide.)
+
+### 3. Box reconcile: a small pass in the fast loop
+
+New pass (beside `internal/domains`): list `domains/` records; for each
+record with a non-empty `route`:
+
+- if the local registry has that route **and its `account`/`app` columns
+ match the record** — this is the authoritative ownership gate — upsert
+ the alias row;
+- if the alias equals an existing `routes.host`, skip and log: real routes
+ always win over aliases;
+- boxes not carrying the primary route skip the record.
+
+Alias rows with no backing record are deleted. The pass rides the agent's
+serial-triggered `fullPass` (2s convergence) with the hourly adopt-timer
+floor beneath it, like every other pass.
+
+DNS and cert-expiry stamping for aliases rides the existing periodic
+`internal/domains` reconcile (same `SetDNSStatus` / acme-cache-file
+helpers, applied to `route_aliases` rows).
+
+Store-door boxes: `internal/edge/storedoor.go` gains an `aclRead` case
+granting fleet boxes read-only access to the `domains/` prefix, so the pass
+works there too (direct-S3 platform boxes are unaffected).
+
+### 4. Edge: an alias resolves to the primary's upstream entry
+
+In `Reload()`, after the host map is built from routes: for each alias
+whose primary host has an entry, the alias key maps to **the identical
+upstream entry** the primary uses — the same value, never a copied `Addr`,
+never a rebuilt proxy — marked as an alias. Consequences, all by
+construction:
+
+- **Hibernation wake is identical**: the activator is keyed on the primary
+ `Route.Host` at every layer (actEntry, lease, live-symlink, cgroup), so a
+ request arriving on the alias wakes the same single instance — no
+ #129-class second wake racing on one socket.
+- **TLS needs nothing new**: `Allowed()` walks the live map, so the alias
+ is cert-eligible; per-hostname HTTP-01 mints on first handshake.
+- **Wildcards are untouched**: alias entries are explicitly skipped in
+ wildcard parent derivation (`currentHosts`) — customer domains wouldn't
+ match the platform shape anyway, but the exclusion is stated, not
+ incidental.
+- **Static primaries work**: all content/backing derivation uses
+ `route.Host` (the primary); the request Host is used only for TLS,
+ cache keying, and usage metering. An alias on a static route serves the
+ primary's channel-pointed content and carries `X-Carlos-Version`. (Rule
+ pinned by test — it is what makes #82's `deploy -kind static -host` on
+ an already-routed app a pure alias attach.)
+- **HA composes** (#152): when multi-upstream routes land, the primary's
+ entry carries the addr set and prober state; aliases inherit failover
+ with zero alias-side changes because they share the entry. Pinned by a
+ test asserting alias and primary resolve to the same entry.
+- **Usage metering** (unit-economics branch): the edge meters per request
+ Host, so an attached domain counts as its own row, attributed to the
+ same route/app. Intended.
+
+### 5. Console door: the `/domains` family
+
+```
+GET /api/cli/apps/{account}/{app}/domains → list
+POST /api/cli/apps/{account}/{app}/domains → attach {host, route?}
+DELETE /api/cli/apps/{account}/{app}/domains/{host} → detach
+```
+
+Auth follows `apiInstancePrincipal` exactly: an app token bound to that
+account+app, or a member's user token. The app-token allowlist grows from
+"instances" to "instances + domains" (`authcli.go`'s registered family).
+Rate limiting keys by token hash, the instances precedent.
+
+Validation, in one exported spot (so #82 adds its rulings there):
+
+- well-formed lowercase ASCII FQDN — no port, no wildcard label, trailing
+ dot stripped; IDN/punycode out of scope (titogo's v1 rule, adopted);
+- denylist: the apps domain and any host under it, the console's own
+ origin host, and the edge's reserved tunnel hostname — all derived from
+ console/deployment config, not hardcoded;
+- `route` must name a route of this app — best-effort at the door
+ (instance records; if the app has exactly one instance, `route` may be
+ omitted and defaults to it), authoritative at reconcile (§3). Operator
+ routes with no instance record (tito's) pass the door and are gated on
+ box.
+
+Idempotency (the titogo contract):
+
+- attach, already attached by the same owner → 200, converging `route` if
+ it changed;
+- attach, owned by someone else → 409 with a neutral "hostname
+ unavailable" — never who owns it;
+- detach, unknown hostname → quiet 200;
+- detach, owned by someone else → quiet 200, no action taken.
+
+Every successful write audits and bumps `control/serial` (strictly after
+the write), so boxes converge in ~2s.
+
+### 6. CLI: `carlos domains`
+
+```
+carlos domains attach -app tito [-route <primary-host>] tickets.example.com
+carlos domains detach -app tito tickets.example.com
+carlos domains list -app tito
+```
+
+`list` shows each hostname's primary route, DNS status, and cert expiry —
+the command alone verifies the result, per the no-infra rule. Status
+source: the box readings sweep (the #144 machinery) grows an additive
+aliases section carrying each alias row's stamped `dns_status` /
+`cert_expires_at`; the console door merges it into the claim listing. A
+claim no rolled box has materialized yet lists with status `pending`.
+Standard `apiSession` plumbing (`apimode.go` wire methods + a
+`domains_cli.go` verb file).
+
+### 7. Titogo wiring
+
+Nothing new to build platform-side for delivery. The router reads
+`TITO_EDGE_ATTACH_URL` (set to this door's collection URL,
+`https://<console>/api/cli/apps/<sqid>/tito/domains`) and reuses
+`CARLOS_INSTANCE_TOKEN` as its bearer. Both deliver through the existing
+`carlos env set -app tito [-secret]` → `SyncExec` → `<host>.config.env`
+chain the router already consumes (`ExecDelivery: "file"`).
+
+Note for the record: `CARLOS_INSTANCE_URL` is a titogo-side env name; the
+platform's contribution is the door URL shape, documented when closing
+the issue.
+
+## Error handling
+
+- Primary route missing or not ready → alias skipped at reload with a log
+ line; never a 502 route to nowhere.
+- Record whose route never appears → sits inert. This is also the rollout
+ property: old box binaries ignore `domains/` entirely, so the console
+ door can open before boxes roll; records materialize when boxes catch up.
+- DNS not yet pointed → HTTP-01 fails, autocert retries on later
+ handshakes; `carlos domains list` shows `dns_status` so the caller sees
+ why.
+- CAS race on simultaneous attach → one winner; the loser gets the neutral
+ 409.
+
+## Testing
+
+- **Registry**: alias CRUD, migrate idempotence, fingerprint's second
+ section changes when aliases change and is byte-stable when they don't.
+- **Reconcile**: materialize on match; account/app mismatch refused;
+ routes-win collision skip; record-gone removal; routeless (`route: ""`)
+ records ignored; multi-record convergence.
+- **Edge**: alias serves the primary's backend; alias and primary share
+ the same upstream entry (the #152 composition pin); `Allowed()` admits
+ the alias; hibernating primary wakes exactly once via the alias (the
+ #129-class regression test); wildcard parent derivation skips aliases;
+ static primary's alias serves content + `X-Carlos-Version`.
+- **Console**: auth matrix (app token right/wrong app, member, non-member
+ byte-identical 404); the four idempotency cases; denylist; CAS race;
+ serial bump after write.
+- **CLI**: attach/detach/list round-trip against a fake console; env built
+ through `carlosTestEnv` with `cmd.Dir` in scratch (the minted-accounts
+ rule).
+
+## Rollout
+
+One PR off main (worktree `route-hostnames`; the primary checkout's
+working tree belongs to the unit-economics session and is untouched).
+Deploy in the usual roll: box binaries + consoles; ordering is safe either
+way (additive records). After deploy: confirm/mint the tito app token,
+`carlos env set` the URL + token, close #151, and notify the titogo agent
+so its PR 5 (the attach/detach client) can proceed.
+
+## Cross-session coordination (recorded 2026-08-12, evening)
+
+- **unit-economics** (same checkout, branch `unit-economics`): touching
+ `internal/edge/edge.go` (metering wrap in ServeHTTP) and console route
+ registrations. Different hunks; whoever lands second rebases. Alias
+ hosts metering as their own rows is intended (§4).
+- **HA / #152 #153** (PR #154, base includes #137): three agreements —
+ alias shares the primary's upstream entry identity; `route_aliases`
+ fingerprints as a separate section; store-door `aclRead` for `domains/`.
+- **#82 session** (spec being written against this shape): concedes
+ second-row materialization; #82 rides this door for console UI +
+ `deploy` auto-attach; owns the first-route case (routeless claim +
+ #137-vocabulary instance record) on the worktree-route-records base.
+ Claim record is the single uniqueness gate for both cases, written
+ through the same helper. #82's spec names #151 as its dependency.
+- **#137** caught up to origin/main by the #82 session; merging is Paul's
+ action. #151 does not consume it.
+
+## Deliberately not in v1 (recorded)
+
+- IDN/punycode hostnames.
+- A platform-side TXT ownership challenge.
+- Migrating `domains.Reconcile`'s minted platform aliases (#148's subject)
+ onto `route_aliases` — a natural follow-up, not this change.
+- Console web UI and `carlos deploy` auto-attach — #82's territory.
+- Wildcard customer domains (`*.example.com` attach).
diff --git a/domains_cli.go b/domains_cli.go
new file mode 100644
index 000000000..4e5330923
--- /dev/null
+++ b/domains_cli.go
@@ -0,0 +1,150 @@
+// The `carlos domains` verb family (Task 10, issue #151): attach a
+// customer's own hostname to an app's route, detach it, or list what an app
+// currently holds — the CLI front over internal/console/apidomains.go, the
+// same instance-gated console door titogo's router speaks with its
+// CARLOS_INSTANCE_TOKEN. API mode only, the same as `carlos instances
+// create/list/delete`: a claim is a console-authorized write, and there is
+// no store-mode equivalent.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "strings"
+ "time"
+)
+
+const domainsUsage = `usage: carlos domains <attach|detach|list> [flags]
+
+ attach -app <a> [-route <r>] [flags] <hostname>
+ claim a customer's hostname for the app, pointed at
+ -route (default: the app's single instance record, if it
+ has exactly one — otherwise -route is required)
+ detach -app <a> [flags] <hostname>
+ release a hostname claim
+ list -app <a> [flags]
+ this app's hostname claims, joined to the fleet's own
+ readings sweep (DNS/CERT-EXPIRES)
+
+ -account <a> account (sqid or name; default "ops")
+ -account-id <id> account sqid, matched as a sqid ONLY
+ -console <origin> console to act through (default: the only logged-in one)
+
+Signed-in only (carlos auth login): a claim is a console-authorized write —
+the console checks the hostname against the deploy's deny list, resolves the
+route, and writes it. The hostname comes live on the box that owns the route
+at its next domains pass.
+`
+
+const domainsAttachUsage = `usage: carlos domains attach -app <a> [-route <r>] [-account <a>] [-account-id <id>] [-console <origin>] <hostname>
+`
+
+const domainsDetachUsage = `usage: carlos domains detach -app <a> [-account <a>] [-account-id <id>] [-console <origin>] <hostname>
+`
+
+const domainsListUsage = `usage: carlos domains list -app <a> [-account <a>] [-account-id <id>] [-console <origin>]
+`
+
+// runDomains dispatches the domains sub-verbs. attach/detach/list are
+// positional words, not flags, and must never reach fs.Parse — the same
+// shape runInstances uses for its own sub-verbs.
+func runDomains(args []string) {
+ if len(args) > 0 {
+ switch args[0] {
+ case "attach":
+ runDomainsAttach(args[1:])
+ return
+ case "detach":
+ runDomainsDetach(args[1:])
+ return
+ case "list":
+ runDomainsList(args[1:])
+ return
+ }
+ }
+ log.Fatal(domainsUsage)
+}
+
+func runDomainsAttach(args []string) {
+ fs := flag.NewFlagSet("domains attach", flag.ExitOnError)
+ app := fs.String("app", "", "app the hostname belongs to")
+ route := fs.String("route", "", "route hostname to attach to")
+ consoleFlag := fs.String("console", "", "console to attach through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ host := strings.TrimSpace(fs.Arg(0))
+ if *app == "" || host == "" || fs.NArg() > 1 {
+ log.Fatal(domainsAttachUsage)
+ }
+ sess := requireAPISession("attach", *consoleFlag)
+ printUsing(stdout, sess)
+ created, resolvedRoute, err := sess.domainAttach(context.Background(), apiAccount(acctRaw, acctIDRaw),
+ *app, host, strings.TrimSpace(*route))
+ if err != nil {
+ log.Fatalf("domains attach: %v", err)
+ }
+ if created {
+ fmt.Fprintf(stdout, "%s %s -> %s\n", colorFor(stdout).ok("attached"), host, resolvedRoute)
+ } else {
+ fmt.Fprintf(stdout, "%s %s -> %s\n", colorFor(stdout).ok("already attached"), host, resolvedRoute)
+ }
+}
+
+func runDomainsDetach(args []string) {
+ fs := flag.NewFlagSet("domains detach", flag.ExitOnError)
+ app := fs.String("app", "", "app the hostname belongs to")
+ consoleFlag := fs.String("console", "", "console to detach through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ host := strings.TrimSpace(fs.Arg(0))
+ if *app == "" || host == "" || fs.NArg() > 1 {
+ log.Fatal(domainsDetachUsage)
+ }
+ sess := requireAPISession("detach", *consoleFlag)
+ printUsing(stdout, sess)
+ removed, err := sess.domainDetach(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, host)
+ if err != nil {
+ log.Fatalf("domains detach: %v", err)
+ }
+ if removed {
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("detached"), host)
+ } else {
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("was not attached"), host)
+ }
+}
+
+func runDomainsList(args []string) {
+ fs := flag.NewFlagSet("domains list", flag.ExitOnError)
+ app := fs.String("app", "", "app whose hostname claims to list")
+ consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ if *app == "" {
+ log.Fatal(domainsListUsage)
+ }
+ sess := requireAPISession("list", *consoleFlag)
+ printUsing(stdout, sess)
+ recs, err := sess.domainList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app)
+ if err != nil {
+ log.Fatalf("domains list: %v", err)
+ }
+ w := newTable(stdout)
+ fmt.Fprintln(w, "HOST\tROUTE\tSTATUS\tDNS\tCERT-EXPIRES")
+ for _, r := range recs {
+ dns := r.DNSStatus
+ if dns == "" {
+ dns = "-"
+ }
+ cert := "-"
+ if r.CertExpiresAt > 0 {
+ cert = time.Unix(r.CertExpiresAt, 0).UTC().Format("2006-01-02")
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Host, r.Route, r.Status, dns, cert)
+ }
+ w.Flush()
+}
diff --git a/domains_cli_test.go b/domains_cli_test.go
new file mode 100644
index 000000000..485b06046
--- /dev/null
+++ b/domains_cli_test.go
@@ -0,0 +1,393 @@
+// Tests for the `carlos domains` verb family — the CLI door onto
+// internal/console/apidomains.go (Task 10, issue #151). Same two-layer shape
+// fleets_cli_test.go established: apiSession-level tests drive the three new
+// client methods (apimode.go) directly against an httptest fake console
+// (path, body, wire-shape round trips), and RunDomains-level tests drive the
+// full verb (flag parsing, printing) against the same kind of fake console,
+// credentials written straight to disk. Usage-error paths (log.Fatal, would
+// kill an in-process test binary) run as a real subprocess, carlosTestEnv +
+// scratch cmd.Dir per CLAUDE.md's rule.
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// ---- apiSession-level: path + body + wire-shape round trips ---------------
+
+// TestAPISessionDomainAttach: domainAttach POSTs {host,route} to the app's
+// domains door and decodes created + the route the server resolved.
+func TestAPISessionDomainAttach(t *testing.T) {
+ var gotPath, gotMethod string
+ var gotBody struct{ Host, Route string }
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod = r.URL.Path, r.Method
+ json.NewDecoder(r.Body).Decode(&gotBody)
+ json.NewEncoder(w).Encode(map[string]any{
+ "host": gotBody.Host, "route": gotBody.Route, "created": true,
+ })
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ created, route, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito",
+ "tickets.example.com", "slug.go.tito.io")
+ if err != nil {
+ t.Fatalf("domainAttach: %v", err)
+ }
+ if gotPath != "/api/cli/apps/bab/tito/domains" || gotMethod != http.MethodPost {
+ t.Fatalf("request = %s %s, want POST .../tito/domains", gotMethod, gotPath)
+ }
+ if gotBody.Host != "tickets.example.com" || gotBody.Route != "slug.go.tito.io" {
+ t.Fatalf("body = %+v", gotBody)
+ }
+ if !created || route != "slug.go.tito.io" {
+ t.Fatalf("domainAttach = created=%v route=%q", created, route)
+ }
+}
+
+// TestAPISessionDomainAttachNoOp: created=false round-trips (the re-attach,
+// nothing-changed case), and the server's resolved route still comes back
+// even when the caller sent none.
+func TestAPISessionDomainAttachNoOp(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{
+ "host": "tickets.example.com", "route": "default.go.tito.io", "created": false,
+ })
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ created, route, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito",
+ "tickets.example.com", "")
+ if err != nil {
+ t.Fatalf("domainAttach: %v", err)
+ }
+ if created || route != "default.go.tito.io" {
+ t.Fatalf("domainAttach = created=%v route=%q, want created=false route=default.go.tito.io", created, route)
+ }
+}
+
+// TestAPISessionDomainAttachConflict: a 409 surfaces the server's
+// "hostname unavailable" message verbatim, apiErrorFromBody's json-first
+// decode doing the work — no special-casing needed on this side.
+func TestAPISessionDomainAttachConflict(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ json.NewEncoder(w).Encode(map[string]any{"error": "hostname unavailable"})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "taken.example.com", "")
+ if err == nil || err.Error() != "hostname unavailable" {
+ t.Fatalf("domainAttach against 409 = %v, want exactly \"hostname unavailable\"", err)
+ }
+}
+
+// TestAPISessionDomainDetach: domainDetach DELETEs with the host
+// path-escaped and decodes removed.
+func TestAPISessionDomainDetach(t *testing.T) {
+ var gotPath, gotMethod string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod = r.URL.EscapedPath(), r.Method
+ json.NewEncoder(w).Encode(map[string]any{"host": "tickets.example.com", "removed": true})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ removed, err := s.domainDetach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com")
+ if err != nil {
+ t.Fatalf("domainDetach: %v", err)
+ }
+ if gotMethod != http.MethodDelete {
+ t.Fatalf("method = %s, want DELETE", gotMethod)
+ }
+ if gotPath != "/api/cli/apps/bab/tito/domains/tickets.example.com" {
+ t.Fatalf("path = %s, want the app's domains door with the host appended", gotPath)
+ }
+ if !removed {
+ t.Fatal("removed = false, want true")
+ }
+}
+
+// TestAPISessionDomainDetachWasNotAttached: removed=false round-trips
+// unchanged — the door's own three-ways-the-same neutrality (spec §5).
+func TestAPISessionDomainDetachWasNotAttached(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"host": "ghost.example.com", "removed": false})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ removed, err := s.domainDetach(context.Background(), apiAcct{Seg: "bab"}, "tito", "ghost.example.com")
+ if err != nil {
+ t.Fatalf("domainDetach: %v", err)
+ }
+ if removed {
+ t.Fatal("removed = true, want false")
+ }
+}
+
+// TestAPISessionDomainList: domainList GETs the domains door and decodes the
+// "domains" wrapper, tolerating rows that omit dns_status/cert_expires_at
+// (a claim no live box has materialized yet — apidomains.go's "pending").
+func TestAPISessionDomainList(t *testing.T) {
+ var gotPath, gotMethod string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod = r.URL.Path, r.Method
+ json.NewEncoder(w).Encode(map[string]any{
+ "domains": []map[string]any{
+ {"host": "tickets.example.com", "route": "slug.go.tito.io", "status": "live",
+ "dns_status": "pointing", "cert_expires_at": 1799999999},
+ {"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending"},
+ },
+ })
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ recs, err := s.domainList(context.Background(), apiAcct{Seg: "bab"}, "tito")
+ if err != nil {
+ t.Fatalf("domainList: %v", err)
+ }
+ if gotPath != "/api/cli/apps/bab/tito/domains" || gotMethod != http.MethodGet {
+ t.Fatalf("request = %s %s, want GET .../tito/domains", gotMethod, gotPath)
+ }
+ if len(recs) != 2 {
+ t.Fatalf("domainList returned %d records, want 2", len(recs))
+ }
+ if recs[0].Host != "tickets.example.com" || recs[0].Route != "slug.go.tito.io" ||
+ recs[0].Status != "live" || recs[0].DNSStatus != "pointing" || recs[0].CertExpiresAt != 1799999999 {
+ t.Fatalf("recs[0] = %+v", recs[0])
+ }
+ if recs[1].Host != "new.example.com" || recs[1].Status != "pending" ||
+ recs[1].DNSStatus != "" || recs[1].CertExpiresAt != 0 {
+ t.Fatalf("recs[1] = %+v, want empty dns_status and zero cert_expires_at", recs[1])
+ }
+}
+
+// TestAPISessionDomainDoorsSurfaceServerErrors: a non-2xx from any of the
+// three domain doors surfaces an error rather than a swallowed one.
+func TestAPISessionDomainDoorsSurfaceServerErrors(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ w.Write([]byte("404 page not found\n"))
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ acct := apiAcct{Seg: "bab"}
+
+ if _, _, err := s.domainAttach(context.Background(), acct, "ghost", "x.example.com", ""); err == nil {
+ t.Fatal("domainAttach against a 404 must error")
+ }
+ if _, err := s.domainDetach(context.Background(), acct, "ghost", "x.example.com"); err == nil {
+ t.Fatal("domainDetach against a 404 must error")
+ }
+ if _, err := s.domainList(context.Background(), acct, "ghost"); err == nil {
+ t.Fatal("domainList against a 404 must error")
+ }
+}
+
+// ---- RunDomains-level: full verb, flag parsing + printing -----------------
+
+// fakeDomainsConsole is a minimal in-process console answering every
+// domains door with fixed, easily-asserted bodies.
+type fakeDomainsConsole struct {
+ ts *httptest.Server
+ lastAttachPath string
+ lastAttachBody struct{ Host, Route string }
+ attachCreated bool
+ lastDetachPath string
+ detachRemoved bool
+}
+
+func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole {
+ t.Helper()
+ fc := &fakeDomainsConsole{attachCreated: true, detachRemoved: true}
+ fc.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/domains"):
+ fc.lastAttachPath = r.URL.Path
+ json.NewDecoder(r.Body).Decode(&fc.lastAttachBody)
+ route := fc.lastAttachBody.Route
+ if route == "" {
+ route = "default.go.tito.io"
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "host": fc.lastAttachBody.Host, "route": route, "created": fc.attachCreated,
+ })
+ case r.Method == http.MethodDelete:
+ fc.lastDetachPath = r.URL.Path
+ json.NewEncoder(w).Encode(map[string]any{"host": "x", "removed": fc.detachRemoved})
+ case r.Method == http.MethodGet:
+ json.NewEncoder(w).Encode(map[string]any{
+ "domains": []map[string]any{
+ {"host": "tickets.example.com", "route": "slug.go.tito.io", "status": "live",
+ "dns_status": "pointing", "cert_expires_at": 1799999999},
+ {"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending"},
+ },
+ })
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ t.Cleanup(fc.ts.Close)
+ return fc
+}
+
+// domainsCLIFixture wires up a fake console plus a credential pointed at it,
+// entirely in-process — the same shape fleetsCLIFixture uses.
+func domainsCLIFixture(t *testing.T) *fakeDomainsConsole {
+ t.Helper()
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("CARLOS_CONSOLE", "")
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", "")
+ t.Chdir(t.TempDir())
+ creds := filepath.Join(home, "carlos-credentials")
+ t.Setenv("CARLOS_CREDENTIALS", creds)
+ fc := newFakeDomainsConsole(t)
+ writeTestCredential(t, creds, fc.ts.URL)
+ return fc
+}
+
+func TestRunDomainsAttachPrintsAttached(t *testing.T) {
+ fc := domainsCLIFixture(t)
+ fc.attachCreated = true
+ buf := captureStdout(t)
+
+ runDomains([]string{"attach", "-app", "tito", "-route", "slug.go.tito.io", "tickets.example.com"})
+
+ if fc.lastAttachBody.Host != "tickets.example.com" || fc.lastAttachBody.Route != "slug.go.tito.io" {
+ t.Fatalf("console saw body %+v", fc.lastAttachBody)
+ }
+ if !strings.Contains(buf.String(), "attached tickets.example.com -> slug.go.tito.io") {
+ t.Fatalf("stdout = %q, want the attached confirmation", buf.String())
+ }
+}
+
+func TestRunDomainsAttachPrintsAlreadyAttachedOnNoOp(t *testing.T) {
+ fc := domainsCLIFixture(t)
+ fc.attachCreated = false
+ buf := captureStdout(t)
+
+ runDomains([]string{"attach", "-app", "tito", "tickets.example.com"})
+
+ if !strings.Contains(buf.String(), "already attached") {
+ t.Fatalf("stdout = %q, want the already-attached confirmation", buf.String())
+ }
+}
+
+func TestRunDomainsDetachPrintsDetached(t *testing.T) {
+ fc := domainsCLIFixture(t)
+ fc.detachRemoved = true
+ buf := captureStdout(t)
+
+ runDomains([]string{"detach", "-app", "tito", "tickets.example.com"})
+
+ if !strings.HasSuffix(fc.lastDetachPath, "/domains/tickets.example.com") {
+ t.Fatalf("detach path = %s, want it to end in the escaped host", fc.lastDetachPath)
+ }
+ if !strings.Contains(buf.String(), "detached tickets.example.com") {
+ t.Fatalf("stdout = %q, want the detached confirmation", buf.String())
+ }
+}
+
+func TestRunDomainsDetachPrintsWasNotAttached(t *testing.T) {
+ fc := domainsCLIFixture(t)
+ fc.detachRemoved = false
+ buf := captureStdout(t)
+
+ runDomains([]string{"detach", "-app", "tito", "ghost.example.com"})
+
+ if !strings.Contains(buf.String(), "was not attached") {
+ t.Fatalf("stdout = %q, want the was-not-attached confirmation", buf.String())
+ }
+}
+
+// TestRunDomainsListPrintsTable: the list table carries every column and
+// falls back to "-" for the row with no dns_status/cert_expires_at.
+func TestRunDomainsListPrintsTable(t *testing.T) {
+ domainsCLIFixture(t)
+ buf := captureStdout(t)
+
+ runDomains([]string{"list", "-app", "tito"})
+
+ out := buf.String()
+ for _, want := range []string{
+ "HOST", "ROUTE", "STATUS", "DNS", "CERT-EXPIRES",
+ "tickets.example.com", "slug.go.tito.io", "live", "pointing", "2027-01-15",
+ "new.example.com", "pending",
+ } {
+ if !strings.Contains(out, want) {
+ t.Fatalf("stdout missing %q:\n%s", want, out)
+ }
+ }
+ // The pending row's dns/cert columns must read "-", not a stray zero or
+ // blank cell that would misalign the table.
+ lines := strings.Split(out, "\n")
+ var pendingLine string
+ for _, l := range lines {
+ if strings.Contains(l, "new.example.com") {
+ pendingLine = l
+ }
+ }
+ if pendingLine == "" || !strings.Contains(pendingLine, "-") {
+ t.Fatalf("pending row = %q, want a \"-\" placeholder for the absent columns", pendingLine)
+ }
+}
+
+// ---- subprocess: the usage-error / bad-argument paths ---------------------
+
+// TestDomainsCLIUsageErrors drives every domains verb with missing or
+// malshaped arguments as a real subprocess (buildCarlos) — the only way to
+// observe a log.Fatal/os.Exit refusal without killing the test binary.
+// carlosTestEnv masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE and cmd.Dir is
+// a scratch dir (CLAUDE.md's rule), so a case that DID try to dial out would
+// fail on "not logged in", not silently succeed.
+func TestDomainsCLIUsageErrors(t *testing.T) {
+ bin := buildCarlos(t)
+ scratch := t.TempDir()
+ env := carlosTestEnv(t)
+
+ run := func(args ...string) (string, error) {
+ cmd := exec.Command(bin, args...)
+ cmd.Env = env
+ cmd.Dir = scratch
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ for _, tc := range []struct {
+ name string
+ args []string
+ want string
+ }{
+ {"domains no args", []string{"domains"}, "usage: carlos domains"},
+ {"domains bogus verb", []string{"domains", "bogus"}, "usage: carlos domains"},
+ {"attach no app", []string{"domains", "attach", "tickets.example.com"}, "usage: carlos domains attach"},
+ {"attach no positional host", []string{"domains", "attach", "-app", "tito"}, "usage: carlos domains attach"},
+ {"detach no app", []string{"domains", "detach", "tickets.example.com"}, "usage: carlos domains detach"},
+ {"detach no positional host", []string{"domains", "detach", "-app", "tito"}, "usage: carlos domains detach"},
+ {"list no app", []string{"domains", "list"}, "usage: carlos domains list"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ out, err := run(tc.args...)
+ if err == nil {
+ t.Fatalf("carlos %s exited 0, want a nonzero (usage) exit:\n%s", strings.Join(tc.args, " "), out)
+ }
+ if !strings.Contains(out, tc.want) {
+ t.Fatalf("carlos %s output missing %q:\n%s", strings.Join(tc.args, " "), tc.want, out)
+ }
+ })
+ }
+}
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
new file mode 100644
index 000000000..ca4ad69d9
--- /dev/null
+++ b/internal/console/apidomains.go
@@ -0,0 +1,335 @@
+// The CLI's extra-hostname API (#151): the verbs that attach a customer's
+// own hostname to an app's route, as a data write in the deployment bucket,
+// so an app holding no AWS credentials and no box access can offer
+// custom-domain hosting to ITS customers in one call. The claim it writes is
+// the whole authorization; the agent's domains pass turns it into an alias
+// row, on the box that actually owns the route.
+//
+// The gate is the instance family's, verbatim (instanceGate, apiinstances.go):
+// the SAME two bearer shapes reach it — a member's user token, or an APP
+// token bound to one account and one app at mint time — and every refusal
+// before that proof is the same byte-identical 404. That reuse is deliberate:
+// titogo's router calls this door with its CARLOS_INSTANCE_TOKEN, the very
+// credential it already holds for instances, and a second hand-rolled gate
+// here would be a second place for the ordering (principal, budget,
+// readiness) to drift.
+//
+// Two neutralities this file exists to keep:
+// - a hostname another account holds answers 409 "hostname unavailable" and
+// NOTHING else — not who holds it, not which account, not whether the
+// holder is in this deployment at all;
+// - detach is quiet in both directions — an unknown hostname and another
+// owner's hostname answer with the identical 200 body, so detach is not a
+// way to ask "does anyone own this?".
+//
+// Spec: docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md §5.
+package console
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net/http"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// deniedDomains is the deny list every attach is checked against: the
+// operator's own CONSOLE_DENY_DOMAINS entries, plus the three names this
+// console can derive without being told (spec §5: "all derived from
+// console/deployment config, not hardcoded").
+//
+// The derived three are not optional and not the operator's to forget:
+//
+// - the ORIGIN host is the console itself — an alias there would point the
+// door's own hostname at a tenant's route;
+// - the APPS DOMAIN is the whole platform apex namespace
+// (<app>.<sqid>.<apps-domain>): every app's public URL lives under it,
+// and a claim there would be one tenant claiming another tenant's
+// address — the sibling-apex hazard handleAPIInstanceCreate refuses
+// within an account, refused here across the deployment. Hosts under the
+// apps domain are what `carlos instances` exists for; this door is for
+// hostnames the caller's own DNS controls;
+// - the REDIRECT HOSTS are retired console origins (CONSOLE_REDIRECT_HOSTS
+// — tito's carlos.tito.io), which this console still answers on and
+// 301s to Origin. They are guaranteed distinct from the origin host
+// (NewServer refuses a self-redirect), so nothing else in this list
+// covers them, and a tenant claiming one would be claiming a console
+// address that operators, bookmarks and old CLI credentials still reach.
+// Route-wins ordering on a box is not the answer: it is per-box, and
+// stops being protection the moment a second box materializes the alias.
+//
+// Derived per call rather than cached at boot — one url.Parse and a short
+// copy on a door that then does bucket I/O — so it stays honest for an
+// embedder (or a test) that sets any of these after NewServer. The redirect
+// entries go through the same normalizeHost ServeHTTP's retired-host check
+// uses, so the two readings of CONSOLE_REDIRECT_HOSTS cannot disagree.
+func (s *Server) deniedDomains() []string {
+ deny := make([]string, 0, len(s.cfg.DenyDomains)+len(s.cfg.RedirectHosts)+2)
+ deny = append(deny, s.cfg.DenyDomains...)
+ if u, err := url.Parse(s.cfg.Origin); err == nil {
+ if h := strings.ToLower(u.Hostname()); h != "" {
+ deny = append(deny, h)
+ }
+ }
+ if d := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)); d != "" {
+ deny = append(deny, d)
+ }
+ for _, h := range s.cfg.RedirectHosts {
+ if h = normalizeHost(h); h != "" {
+ deny = append(deny, h)
+ }
+ }
+ return deny
+}
+
+// resolveAliasRoute decides which of the app's routes a claim points at.
+// ok=false means a response has already been written.
+//
+// An EXPLICIT route is shape-checked and then accepted as given. The door
+// deliberately does not verify that the route exists: instance records are
+// only one way a route comes to be, and an operator-minted route (tito's
+// go.tito.io, every route on a self-hosted box) has no record at all —
+// refusing those here would make the feature unusable for exactly the
+// deployment that asked for it. The authoritative check is on the box, at
+// reconcile: a claim naming a route that box does not own materializes
+// nothing (spec §3), so a wrong route is inert rather than dangerous.
+//
+// An OMITTED route is defaulted only when there is exactly one thing it could
+// mean — the app's single instance record. Zero or several is refused, naming
+// the flag that resolves it: guessing an app's primary route out of a set
+// would attach a customer's hostname to whichever one sorted first.
+func (s *Server) resolveAliasRoute(ctx context.Context, w http.ResponseWriter, acct *Account, app, want string) (string, bool) {
+ if route := strings.ToLower(strings.TrimSpace(want)); route != "" {
+ if !provision.ValidHostShape(route) {
+ jsonError(w, http.StatusBadRequest, "invalid route hostname")
+ return "", false
+ }
+ return route, true
+ }
+ recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, acct.Sqid, app, instanceListMax)
+ if err != nil {
+ log.Printf("console: api domains route default %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read this app's instances")
+ return "", false
+ }
+ if len(recs) == 1 {
+ return recs[0].Host, true
+ }
+ if len(recs) == 0 {
+ jsonError(w, http.StatusBadRequest,
+ "specify -route: the app has no instance records to default to — name the route hostname to attach to")
+ return "", false
+ }
+ jsonError(w, http.StatusBadRequest,
+ "specify -route: the app has "+strconv.Itoa(len(recs))+" instances")
+ return "", false
+}
+
+// handleAPIDomainAttach is the point of the feature: one claim written to the
+// bucket, in the spec's refusal order — gate, body, hostname, deny list,
+// route, write. Idempotent by design (the titogo contract): re-attaching a
+// hostname this app already holds is 200 with created false, converging the
+// route if it moved, and a hostname anyone ELSE holds is the neutral 409.
+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 string `json:"host"`
+ Route string `json:"route"`
+ }
+ if !readJSON(w, r, &in) {
+ return
+ }
+ // Path-traversal defence stated as such: this hostname becomes a bucket
+ // key segment (domains/<host>.json) and, on the box, a registry alias row
+ // and a certificate name. Nothing below runs until it is a well-formed,
+ // lowercased, dot-bearing hostname.
+ host, ok := domains.NormalizeAliasHost(in.Host)
+ if !ok {
+ jsonError(w, http.StatusBadRequest,
+ "invalid hostname — attach a full hostname like tickets.example.com")
+ 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, changed, err := domains.AttachClaim(ctx, s.cfg.Releases, domains.Claim{
+ Host: host, Account: acct.Sqid, App: app, Route: route,
+ // UTC, like every other timestamp written to the bucket: these claims
+ // are read on boxes in other regions.
+ CreatedAt: time.Now().UTC().Format(time.RFC3339), CreatedBy: principal,
+ })
+ if errors.Is(err, domains.ErrHostUnavailable) {
+ // Exactly these bytes, whoever holds it: naming the holder — or even
+ // distinguishing "held by another app of yours" from "held by a
+ // stranger" — would make this door a hostname-ownership oracle.
+ 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
+ }
+ // Only an actual WRITE audits and announces (spec §5), the same rule
+ // detach observes below. `created` is the caller's answer; `changed` is
+ // this one, and they differ on the converge — a re-attach that repointed
+ // the route created nothing but must reach the fleet. The no-op case is
+ // not hypothetical: titogo's router re-attaches every hostname on each
+ // reload pass, so a bump per call would keep every box converging onto
+ // nothing and an audit row per call would bury the tenant's log.
+ if changed {
+ s.audit(acct, app, principal, "domain-attach", "attached hostname "+host+" -> "+route+" (cli)")
+ // The claim is what a box's domains pass mints an alias row from, so
+ // this is a box-consumed write (carlos-deploy §3): announce it, and
+ // the hostname is serving seconds after the call instead of on the
+ // hour. Strictly after the write and the audit — a serial that moved
+ // for a write that failed would send every box looking for nothing.
+ s.bumpSerial(ctx)
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"host": host, "route": route, "created": created})
+}
+
+// handleAPIDomainDetach releases a hostname. Always 200, whatever was there:
+// `removed` reports whether this call is what removed it, and the three ways
+// of removing nothing — already gone, never claimed, claimed by someone else
+// — are indistinguishable on the wire (DetachClaim's own neutrality, spec
+// §5). Only an EFFECTIVE removal audits and bumps the serial: a no-op changed
+// nothing for a box to converge onto, and an audit row per probe would let a
+// prober write to someone else's account log.
+func (s *Server) handleAPIDomainDetach(w http.ResponseWriter, r *http.Request) {
+ principal, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ // Shape-checked before the key builder, not after: a {host} segment
+ // arrives percent-decoded, so "..%2f..%2finstances.json" reaches this
+ // handler as a path with separators in it, and ClaimKey would happily
+ // join it. There is no path through this handler on which an
+ // unnormalized host reaches the store.
+ host, ok := domains.NormalizeAliasHost(r.PathValue("host"))
+ if !ok {
+ jsonError(w, http.StatusBadRequest, "invalid hostname")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ removed, err := domains.DetachClaim(ctx, s.cfg.Releases, host, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: api domains detach %s/%s %s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not detach the hostname — try again")
+ return
+ }
+ if removed {
+ s.audit(acct, app, principal, "domain-detach", "detached hostname "+host+" (cli)")
+ // Taking a hostname down is the direction people want FAST.
+ s.bumpSerial(ctx)
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"host": host, "removed": removed})
+}
+
+// domainListItem is one row of the list answer — the claim's desired state
+// (host, route) joined with what the fleet actually reports for it. Status is
+// "live" or "pending" and never absent; the two reading columns omit when
+// unstamped, the same unknown-stays-absent convention status.AliasReading
+// itself uses.
+type domainListItem struct {
+ Host string `json:"host"`
+ Route string `json:"route"`
+ Status string `json:"status"`
+ DNSStatus string `json:"dns_status,omitempty"`
+ CertExpiresAt int64 `json:"cert_expires_at,omitempty"`
+}
+
+// handleAPIDomainList answers `carlos domains list`: this app's claims, each
+// joined to the box readings sweep so the command alone verifies its own
+// result (the no-infra rule) — DNS status and cert expiry as the box stamped
+// them, or "pending" for a claim no live box has materialized yet.
+func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ // Claims are keyed globally (domains/<host>.json — the key IS the
+ // cross-account uniqueness gate), so the tenant boundary is this filter
+ // and nothing else: every row that reaches the answer matched BOTH this
+ // account's sqid and this app.
+ claims, err := domains.ListClaims(ctx, s.cfg.Releases)
+ if err != nil {
+ log.Printf("console: api domains list %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not list the hostnames")
+ return
+ }
+ readings := aliasReadings(ctx, s.cfg.Releases, time.Now())
+ out := make([]domainListItem, 0, len(claims))
+ for _, c := range claims {
+ if c.Account != acct.Sqid || c.App != app {
+ continue
+ }
+ item := domainListItem{Host: c.Host, Route: c.Route, Status: "pending"}
+ if reading, ok := readings[c.Host]; ok {
+ item.Status = "live"
+ item.DNSStatus = reading.DNSStatus
+ item.CertExpiresAt = reading.CertExpiresAt
+ }
+ out = append(out, item)
+ }
+ // Host-sorted, because the Store.List contract does not promise an order
+ // and a terminal listing that shuffles between calls reads as churn.
+ sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
+ writeJSON(w, http.StatusOK, map[string]any{"domains": out})
+}
+
+// aliasReadings builds a hostname -> reading map from every NON-STALE box's
+// own private Metrics.Aliases (status/boxes/*.json), through status.LoadBoxes
+// — the same loader and the same staleness rule stagedVersions uses
+// (apiinstances.go), for the same reason: a box that stopped reporting is
+// EXCLUDED entirely rather than contributing its last-known reading, so a
+// hostname whose only box went dark reads "pending" instead of going on
+// claiming a cert expiry nobody has checked in a week. now is passed in, not
+// read internally, so one request votes on staleness with one clock.
+//
+// Best-effort throughout: a store that cannot be listed degrades to "nothing
+// known" — every claim then lists as pending — rather than failing the call.
+// This join is observability on top of the listing, never load-bearing for
+// it. A nil store is not reachable here (instanceGate's 503 comes first), but
+// the check is kept for any future caller.
+func aliasReadings(ctx context.Context, s release.Store, now time.Time) map[string]status.AliasReading {
+ if s == nil {
+ return nil
+ }
+ boxes, err := status.LoadBoxes(ctx, s)
+ if err != nil {
+ log.Printf("console: api domains: could not load status boxes for hostname readings: %v", err)
+ return nil
+ }
+ readings := map[string]status.AliasReading{}
+ for _, b := range boxes {
+ if b.Metrics == nil || status.BoxStale(b, now, status.SnapshotPeriod) {
+ continue
+ }
+ for host, reading := range b.Metrics.Aliases {
+ readings[host] = reading
+ }
+ }
+ return readings
+}
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go
new file mode 100644
index 000000000..b5462cc23
--- /dev/null
+++ b/internal/console/apidomains_test.go
@@ -0,0 +1,480 @@
+package console
+
+// The domains door (apidomains.go): attach, detach and list of the extra
+// hostnames an app's routes answer on (#151). The properties that matter, in
+// the order they'd hurt if lost: the gate is the instance family's, so an app
+// token bound elsewhere gets the byte-identical 404 and learns nothing; a
+// hostname someone else holds answers a NEUTRAL 409 that never names the
+// holder; detach is quiet in both directions (unknown host and another
+// owner's host read alike, and neither touches the claim or the serial); and
+// every effective write — and only an effective write — bumps control/serial
+// so boxes converge in seconds.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+func domainsURL(fx *consoleFixture, acct, app string) string {
+ return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/domains"
+}
+
+// attachBody is the door's request shape. route is always sent (empty means
+// "default it"), because an omitted field and an empty one must behave the
+// same and the tests exercise the empty one.
+func attachBody(host, route string) string {
+ b, err := json.Marshal(map[string]string{"host": host, "route": route})
+ if err != nil {
+ panic(err)
+ }
+ return string(b)
+}
+
+type domainAttachResp struct {
+ Host string `json:"host"`
+ Route string `json:"route"`
+ Created bool `json:"created"`
+}
+
+type domainDetachResp struct {
+ Host string `json:"host"`
+ Removed bool `json:"removed"`
+}
+
+type domainListResp struct {
+ Domains []struct {
+ Host string `json:"host"`
+ Route string `json:"route"`
+ Status string `json:"status"`
+ DNSStatus string `json:"dns_status"`
+ CertExpiresAt int64 `json:"cert_expires_at"`
+ } `json:"domains"`
+}
+
+// attachDomain posts one attach and returns the decoded answer, failing the
+// test on any status but the one asked for.
+func attachDomain(t *testing.T, fx *consoleFixture, token, host, route string) domainAttachResp {
+ t.Helper()
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), token, attachBody(host, route))
+ if code != http.StatusOK {
+ t.Fatalf("attach %s = %d %s, want 200", host, code, out)
+ }
+ var got domainAttachResp
+ if err := json.Unmarshal([]byte(out), &got); err != nil {
+ t.Fatalf("attach body %s: %v", out, err)
+ }
+ return got
+}
+
+// claimOf reads a claim straight from the bucket — the store side of every
+// assertion here, so a handler that answers 200 without writing is caught.
+func claimOf(t *testing.T, fx *consoleFixture, host string) *domains.Claim {
+ t.Helper()
+ c, err := domains.GetClaim(context.Background(), fx.rel, host)
+ if err != nil {
+ t.Fatalf("claim %s: %v", host, err)
+ }
+ return c
+}
+
+func claimAbsent(t *testing.T, fx *consoleFixture, host string) {
+ t.Helper()
+ if _, err := domains.GetClaim(context.Background(), fx.rel, host); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("claim %s exists (err=%v), want none", host, err)
+ }
+}
+
+// TestAPIDomainAttachCreates: a member's token attaches a hostname to a
+// named route — 200, created true, and a claim object in the bucket carrying
+// the member as its author.
+func TestAPIDomainAttachCreates(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-create")
+ got := attachDomain(t, fx, member, "tickets.example.com", "go.tito.io")
+ if got != (domainAttachResp{Host: "tickets.example.com", Route: "go.tito.io", Created: true}) {
+ t.Fatalf("attach answer = %+v, want the host/route echoed with created true", got)
+ }
+ c := claimOf(t, fx, "tickets.example.com")
+ if c.Schema != 1 || c.Account != "bab" || c.App != "titogo" || c.Route != "go.tito.io" {
+ t.Fatalf("claim = %+v, want schema 1 bab/titogo -> go.tito.io", *c)
+ }
+ if c.CreatedBy != "alice@keymail.dev" {
+ t.Fatalf("created_by = %q, want the member's address", c.CreatedBy)
+ }
+ if c.CreatedAt == "" {
+ t.Fatalf("claim has no created_at: %+v", *c)
+ }
+ // The hostname is normalized before it is stored: a caller shouting the
+ // host with a trailing dot claims the same object, not a second one.
+ if got := attachDomain(t, fx, member, "TICKETS.example.com.", "go.tito.io"); got.Created || got.Host != "tickets.example.com" {
+ t.Fatalf("re-attach of the same host in another case = %+v, want the normalized host, created false", got)
+ }
+}
+
+// TestAPIDomainAttachAppToken: the app-held credential works on its own app
+// — and a token bound to a NEIGHBOURING app in the same account meets the
+// family's byte-identical 404 (apiInstancePrincipal's contract), never a
+// hint that the path exists.
+func TestAPIDomainAttachAppToken(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-dom-apptoken")
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "alice@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ mine, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ foreign, err := fx.st.NewAppToken(OpsAccountID, "other", "alice@keymail.dev", "signup", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := attachDomain(t, fx, mine, "tickets.example.com", "go.tito.io")
+ if !got.Created {
+ t.Fatalf("app token attach = %+v, want created true", got)
+ }
+ if c := claimOf(t, fx, "tickets.example.com"); c.CreatedBy != "app:bab/titogo" {
+ t.Fatalf("created_by = %q, want the app principal app:bab/titogo", c.CreatedBy)
+ }
+ want := canonicalNotFound(t)
+ for _, c := range []struct{ what, method, url, body string }{
+ {"foreign app token attach", http.MethodPost, domainsURL(fx, "bab", "titogo"), attachBody("other.example.com", "go.tito.io")},
+ {"foreign app token list", http.MethodGet, domainsURL(fx, "bab", "titogo"), ""},
+ {"foreign app token detach", http.MethodDelete, domainsURL(fx, "bab", "titogo") + "/tickets.example.com", ""},
+ } {
+ code, h, out := rawReq(t, c.method, c.url, foreign, c.body)
+ assertNotFound(t, c.what, want, code, h, out)
+ }
+ // The refused calls changed nothing: no new claim, and the one that
+ // existed is still there.
+ claimAbsent(t, fx, "other.example.com")
+ claimOf(t, fx, "tickets.example.com")
+}
+
+// TestAPIDomainAttachIdempotent: attach is the caller's GOAL, not an event —
+// a second identical attach is 200 with created false, and an attach naming a
+// different route converges the claim in place rather than refusing it.
+func TestAPIDomainAttachIdempotent(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-idem")
+ if got := attachDomain(t, fx, member, "tickets.example.com", "go.tito.io"); !got.Created {
+ t.Fatalf("first attach = %+v, want created true", got)
+ }
+ if got := attachDomain(t, fx, member, "tickets.example.com", "go.tito.io"); got.Created {
+ t.Fatalf("second identical attach = %+v, want created false", got)
+ }
+ if got := attachDomain(t, fx, member, "tickets.example.com", "two.go.tito.io"); got.Created || got.Route != "two.go.tito.io" {
+ t.Fatalf("re-attach to another route = %+v, want created false on the new route", got)
+ }
+ if c := claimOf(t, fx, "tickets.example.com"); c.Route != "two.go.tito.io" {
+ t.Fatalf("claim route = %q, want the converged two.go.tito.io", c.Route)
+ }
+}
+
+// TestAPIDomainAttachConflictNeutral: a hostname another account holds is a
+// 409 whose body says exactly one thing — never who holds it, never whether
+// the holder is in this deployment at all.
+func TestAPIDomainAttachConflictNeutral(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-conflict")
+ if _, _, err := domains.AttachClaim(context.Background(), fx.rel, domains.Claim{
+ Host: "tickets.example.com", Account: "cdef", App: "widgets",
+ Route: "widgets.example.net", CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ }); err != nil {
+ t.Fatal(err)
+ }
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
+ attachBody("tickets.example.com", "go.tito.io"))
+ if code != http.StatusConflict {
+ t.Fatalf("attach of a held hostname = %d %s, want 409", code, out)
+ }
+ if got := strings.TrimSpace(out); got != `{"error":"hostname unavailable"}` {
+ t.Fatalf("409 body = %s, want the neutral {\"error\":\"hostname unavailable\"}", got)
+ }
+ // The holder's claim is untouched by the refusal.
+ if c := claimOf(t, fx, "tickets.example.com"); c.Account != "cdef" || c.App != "widgets" {
+ t.Fatalf("claim after a refused attach = %+v, want the holder's own", *c)
+ }
+}
+
+// TestAPIDomainAttachValidation: every hostname this door refuses to take,
+// each with nothing written behind it — malformed, bare-label, under a
+// configured deny domain, under the deployment's apps domain, the console's
+// own origin host, and a RETIRED console origin (CONSOLE_REDIRECT_HOSTS,
+// which this console still answers on and 301s away — tito's carlos.tito.io).
+func TestAPIDomainAttachValidation(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-validate")
+ fx.srv.cfg.DenyDomains = []string{"", "denied.test"}
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ fx.srv.cfg.RedirectHosts = []string{"", "Carlos.Tito.Test:8443"}
+ origin, err := url.Parse(fx.srv.cfg.Origin)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, host := range []string{
+ "Bad_Host!", // not a hostname at all
+ "nodots", // a bare label is never a public hostname
+ "x.denied.test", // under a CONSOLE_DENY_DOMAINS entry
+ "denied.test", // the deny entry itself
+ "one.bab.oncarlos.test", // platform apex space (spec §5)
+ origin.Hostname(), // the console's own origin host
+ "carlos.tito.test", // a retired console origin, normalized as ServeHTTP normalizes it
+ "old.carlos.tito.test", // and anything under it
+ } {
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
+ attachBody(host, "go.tito.io"))
+ if code != http.StatusBadRequest {
+ t.Errorf("attach %q = %d %s, want 400", host, code, out)
+ }
+ claimAbsent(t, fx, strings.ToLower(host))
+ }
+ // An empty deny entry matches nothing: the ordinary hostname still goes
+ // through with "" in the list.
+ if got := attachDomain(t, fx, member, "tickets.example.com", "go.tito.io"); !got.Created {
+ t.Fatalf("ordinary attach with an empty deny entry present = %+v, want created true", got)
+ }
+}
+
+// TestAPIDomainAttachRouteDefault: the route may be omitted when there is
+// exactly one thing it could mean. Two instance records make it ambiguous,
+// and ambiguity names the flag that resolves it rather than guessing.
+func TestAPIDomainAttachRouteDefault(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-route")
+ ctx := context.Background()
+ rec := provision.Record{Schema: 1, Host: "one.go.tito.io", Slug: "one", App: "titogo",
+ Account: "bab", Channel: "stable", CreatedAt: time.Now().UTC()}
+ if err := provision.CreateRecord(ctx, fx.rel, rec); err != nil {
+ t.Fatal(err)
+ }
+ if got := attachDomain(t, fx, member, "tickets.example.com", ""); got.Route != "one.go.tito.io" {
+ t.Fatalf("attach with no route = %+v, want the app's one instance as the route", got)
+ }
+ if c := claimOf(t, fx, "tickets.example.com"); c.Route != "one.go.tito.io" {
+ t.Fatalf("claim route = %q, want one.go.tito.io", c.Route)
+ }
+ rec.Host, rec.Slug = "two.go.tito.io", "two"
+ if err := provision.CreateRecord(ctx, fx.rel, rec); err != nil {
+ t.Fatal(err)
+ }
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
+ attachBody("other.example.com", ""))
+ if code != http.StatusBadRequest {
+ t.Fatalf("ambiguous attach = %d %s, want 400", code, out)
+ }
+ if !strings.Contains(out, "-route") {
+ t.Fatalf("ambiguous attach body = %s, want it to name the -route flag", out)
+ }
+ claimAbsent(t, fx, "other.example.com")
+}
+
+// TestAPIDomainDetachQuiet: detach answers the same 200 shape three ways —
+// the claim it removed, a hostname nobody claimed, and one another account
+// holds — so a caller cannot use detach to discover who owns a hostname.
+// Only the first of the three actually removes anything.
+func TestAPIDomainDetachQuiet(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-detach")
+ attachDomain(t, fx, member, "tickets.example.com", "go.tito.io")
+ if _, _, err := domains.AttachClaim(context.Background(), fx.rel, domains.Claim{
+ Host: "theirs.example.com", Account: "cdef", App: "widgets",
+ Route: "widgets.example.net", CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ }); err != nil {
+ t.Fatal(err)
+ }
+ detach := func(host string) domainDetachResp {
+ t.Helper()
+ code, _, out := rawReq(t, http.MethodDelete, domainsURL(fx, "bab", "titogo")+"/"+host, member, "")
+ if code != http.StatusOK {
+ t.Fatalf("detach %s = %d %s, want 200", host, code, out)
+ }
+ var got domainDetachResp
+ if err := json.Unmarshal([]byte(out), &got); err != nil {
+ t.Fatalf("detach body %s: %v", out, err)
+ }
+ return got
+ }
+ if got := detach("tickets.example.com"); got != (domainDetachResp{Host: "tickets.example.com", Removed: true}) {
+ t.Fatalf("detach of an own claim = %+v, want removed true", got)
+ }
+ claimAbsent(t, fx, "tickets.example.com")
+ if got := detach("tickets.example.com"); got.Removed {
+ t.Fatalf("second detach = %+v, want removed false", got)
+ }
+ if got := detach("never.example.com"); got != (domainDetachResp{Host: "never.example.com", Removed: false}) {
+ t.Fatalf("detach of an unknown hostname = %+v, want the same shape, removed false", got)
+ }
+ if got := detach("theirs.example.com"); got != (domainDetachResp{Host: "theirs.example.com", Removed: false}) {
+ t.Fatalf("detach of another account's claim = %+v, want the same quiet answer", got)
+ }
+ if c := claimOf(t, fx, "theirs.example.com"); c.Account != "cdef" || c.App != "widgets" {
+ t.Fatalf("another account's claim after a detach = %+v, want it intact", *c)
+ }
+ // Traversal defence: a {host} arrives percent-decoded, so a path with
+ // separators in it reaches the handler. It is refused on shape, before
+ // anything builds a bucket key from it.
+ for _, bad := range []string{"bad_host", "..%2F..%2Finstances.json"} {
+ if code, _, out := rawReq(t, http.MethodDelete,
+ domainsURL(fx, "bab", "titogo")+"/"+bad, member, ""); code != http.StatusBadRequest {
+ t.Errorf("detach %q = %d %s, want 400", bad, code, out)
+ }
+ }
+}
+
+// TestAPIDomainList: this app's claims only, each joined to the box readings
+// sweep — a hostname a live box reports carries its DNS/cert columns and
+// reads "live"; one no live box reports reads "pending", including one only a
+// STALE box still remembers.
+func TestAPIDomainList(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-list")
+ ctx := context.Background()
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "alice@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ attachDomain(t, fx, member, "live.example.com", "go.tito.io")
+ attachDomain(t, fx, member, "pending.example.com", "go.tito.io")
+ attachDomain(t, fx, member, "stale.example.com", "go.tito.io")
+ if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{
+ Host: "elsewhere.example.com", Account: "bab", App: "other",
+ Route: "other.go.tito.io", CreatedAt: now.UTC().Format(time.RFC3339),
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := status.WriteBox(ctx, fx.rel, status.Box{
+ GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1",
+ Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{
+ "live.example.com": {Route: "go.tito.io", DNSStatus: "ok", CertExpiresAt: 1786000000},
+ "elsewhere.example.com": {Route: "other.go.tito.io", DNSStatus: "ok"},
+ }},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ // A box that stopped reporting drops OUT of the join (the stagedVersions
+ // rule): its last-known reading must not go on answering as if current.
+ if err := status.WriteBox(ctx, fx.rel, status.Box{
+ GeneratedAt: now.Add(-2 * time.Hour), Label: "bob-silent", Region: "eu-west-1",
+ Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{
+ "stale.example.com": {Route: "go.tito.io", DNSStatus: "ok", CertExpiresAt: 1786000000},
+ }},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "")
+ if code != http.StatusOK {
+ t.Fatalf("list = %d %s, want 200", code, out)
+ }
+ var got domainListResp
+ if err := json.Unmarshal([]byte(out), &got); err != nil {
+ t.Fatalf("list body %s: %v", out, err)
+ }
+ if len(got.Domains) != 3 {
+ t.Fatalf("list = %+v, want exactly this app's three claims", got.Domains)
+ }
+ byHost := map[string]struct {
+ Host string `json:"host"`
+ Route string `json:"route"`
+ Status string `json:"status"`
+ DNSStatus string `json:"dns_status"`
+ CertExpiresAt int64 `json:"cert_expires_at"`
+ }{}
+ for _, d := range got.Domains {
+ byHost[d.Host] = d
+ }
+ if _, ok := byHost["elsewhere.example.com"]; ok {
+ t.Fatalf("another app's claim is in this app's listing: %+v", got.Domains)
+ }
+ live := byHost["live.example.com"]
+ if live.Status != "live" || live.DNSStatus != "ok" || live.CertExpiresAt != 1786000000 || live.Route != "go.tito.io" {
+ t.Fatalf("reported hostname = %+v, want live with its DNS/cert columns", live)
+ }
+ if p := byHost["pending.example.com"]; p.Status != "pending" || p.DNSStatus != "" || p.CertExpiresAt != 0 {
+ t.Fatalf("unreported hostname = %+v, want pending with no readings", p)
+ }
+ if s := byHost["stale.example.com"]; s.Status != "pending" || s.DNSStatus != "" {
+ t.Fatalf("hostname only a stale box reports = %+v, want pending", s)
+ }
+}
+
+// domainEvents counts this app's audit rows whose action starts "domain-".
+func domainEvents(t *testing.T, fx *consoleFixture) int {
+ t.Helper()
+ events, err := fx.st.Events(OpsAccountID, "titogo", 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ n := 0
+ for _, e := range events {
+ if strings.HasPrefix(e.Action, "domain-") {
+ n++
+ }
+ }
+ return n
+}
+
+// TestAPIDomainSerialBump: boxes converge off control/serial, so every
+// EFFECTIVE write announces itself — and the two no-ops, a re-attach that
+// changed nothing and a detach that removed nothing, announce nothing and
+// write no audit row. That matters more than it looks: titogo's router
+// re-attaches its hostnames on every reload pass, so redundant attaches are
+// the STEADY STATE — a bump per pass would keep the whole fleet converging
+// onto nothing, and an audit row per pass would bury the tenant's log.
+func TestAPIDomainSerialBump(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-serial")
+ if got := serial(t, fx); got != 0 {
+ t.Fatalf("serial before any domain write = %d, want 0", got)
+ }
+ attachDomain(t, fx, member, "tickets.example.com", "go.tito.io")
+ if got := serial(t, fx); got != 1 {
+ t.Fatalf("serial after an attach = %d, want 1", got)
+ }
+ if got := domainEvents(t, fx); got != 1 {
+ t.Fatalf("audit rows after an attach = %d, want 1", got)
+ }
+ // The router's steady state: the same attach again, changing nothing.
+ if got := attachDomain(t, fx, member, "tickets.example.com", "go.tito.io"); got.Created {
+ t.Fatalf("no-op re-attach = %+v, want created false", got)
+ }
+ if got := serial(t, fx); got != 1 {
+ t.Fatalf("serial after a no-op re-attach = %d, want it unmoved at 1", got)
+ }
+ if got := domainEvents(t, fx); got != 1 {
+ t.Fatalf("audit rows after a no-op re-attach = %d, want it unmoved at 1", got)
+ }
+ // A converge IS a write: new route, so the fleet hears about it.
+ if got := attachDomain(t, fx, member, "tickets.example.com", "two.go.tito.io"); got.Created {
+ t.Fatalf("converge attach = %+v, want created false", got)
+ }
+ if got := serial(t, fx); got != 2 {
+ t.Fatalf("serial after a converge = %d, want 2", got)
+ }
+ if got := domainEvents(t, fx); got != 2 {
+ t.Fatalf("audit rows after a converge = %d, want 2", got)
+ }
+ if code, _, out := rawReq(t, http.MethodDelete,
+ domainsURL(fx, "bab", "titogo")+"/tickets.example.com", member, ""); code != http.StatusOK {
+ t.Fatalf("detach = %d %s, want 200", code, out)
+ }
+ if got := serial(t, fx); got != 3 {
+ t.Fatalf("serial after an effective detach = %d, want 3", got)
+ }
+ if got := domainEvents(t, fx); got != 3 {
+ t.Fatalf("audit rows after an effective detach = %d, want 3", got)
+ }
+ if code, _, out := rawReq(t, http.MethodDelete,
+ domainsURL(fx, "bab", "titogo")+"/never.example.com", member, ""); code != http.StatusOK {
+ t.Fatalf("no-op detach = %d %s, want 200", code, out)
+ }
+ if got := serial(t, fx); got != 3 {
+ t.Fatalf("serial after a no-op detach = %d, want it unmoved at 3", got)
+ }
+ if got := domainEvents(t, fx); got != 3 {
+ t.Fatalf("audit rows after a no-op detach = %d, want it unmoved at 3", got)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index b2c0a2c42..48e177bea 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -261,8 +261,8 @@ func (s *Server) registerCLIRoutes() {
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append", s.handleAPILedgerAppend)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/blobs", s.handleAPILedgerBlob)
// The instance provisioning API (apiinstances.go) — the only door onto
- // instances/ from outside a box, and the only one an APP's own token
- // may speak to. `instance-token` is a literal segment beside the
+ // instances/ and domains/ from outside a box, and the only family an
+ // APP's own token may speak to. `instance-token` is a literal segment beside the
// {host} wildcards; the mux prefers a literal regardless of
// registration order, and it deliberately sits under a DIFFERENT
// prefix from instances/{host} so a host can never be mistaken for the
@@ -279,6 +279,13 @@ func (s *Server) registerCLIRoutes() {
s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/instances/{host}", s.handleAPIInstanceGet)
s.mux.HandleFunc("DELETE /api/cli/apps/{account}/{app}/instances/{host}", s.handleAPIInstanceDelete)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/instance-token", s.handleAPIInstanceToken)
+ // The extra-hostname API (apidomains.go, issue #151) — the only door onto
+ // domains/ from outside a box, sharing the instance family's gate exactly
+ // (instanceGate), so titogo's router attaches its customers' hostnames
+ // with the CARLOS_INSTANCE_TOKEN it already holds.
+ 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)
// The config API (apiconfig.go) — `carlos env` and `carlos secrets`
// through the same gate as ship/promote, so deploy access no longer
// means bucket credentials (issue #43). Secrets arrive already sealed;
diff --git a/internal/console/server.go b/internal/console/server.go
index fada08534..f89334711 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -210,6 +210,15 @@ type Config struct {
// host-agnostic, and POSTs through an unexpected redirect are how
// clients break) simply aren't listed.
RedirectHosts []string
+ // DenyDomains are hostnames (and every host under them) the /domains
+ // door refuses to attach — CONSOLE_DENY_DOMAINS, comma-separated. The
+ // deployment's own names, in other words: the edge's reserved tunnel
+ // hostname, retired console origins, anything platform-managed that is
+ // not already derivable from this config. The two the console DOES
+ // derive itself — Origin's hostname and AppsDomain — are added by
+ // deniedDomains() (apidomains.go), so a deployment cannot forget them.
+ // Empty is legal and means "only the derived two".
+ DenyDomains []string
// StoreProvisioner is the backend the object-storage doors provision
// through (issue #55), built at boot from CARLOS_STORE_BACKEND. nil is an
// ordinary deployment shape, not a broken one: members may still DECLARE
diff --git a/internal/domains/aliases.go b/internal/domains/aliases.go
new file mode 100644
index 000000000..46a90b812
--- /dev/null
+++ b/internal/domains/aliases.go
@@ -0,0 +1,74 @@
+// Alias reconcile pass — derives the registry's route_aliases table from
+// the global claims recorded in the bucket (#151). See claims.go for the
+// claim shape and registry.go's RouteAlias for the storage shape; this file
+// is the seam that turns one into the other.
+package domains
+
+import (
+ "context"
+ "log"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// 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
+}
diff --git a/internal/domains/aliases_test.go b/internal/domains/aliases_test.go
new file mode 100644
index 000000000..3e202c599
--- /dev/null
+++ b/internal/domains/aliases_test.go
@@ -0,0 +1,245 @@
+package domains
+
+import (
+ "context"
+ "encoding/json"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// aliasRegWith opens a fresh registry and seeds it with routes, the same
+// pattern reconcile_test.go's regWith uses (that helper lives in the
+// domains_test package, unreachable from here).
+func aliasRegWith(t *testing.T, routes ...registry.Route) *registry.Registry {
+ t.Helper()
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, rt := range routes {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return reg
+}
+
+// putClaim writes c's JSON straight to its ClaimKey, bypassing AttachClaim's
+// CAS dance — the reconcile pass reads whatever is in the bucket, not just
+// what AttachClaim would have produced.
+func putClaim(t *testing.T, s release.Store, c Claim) {
+ t.Helper()
+ c.Schema = 1
+ body, err := json.Marshal(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(context.Background(), ClaimKey(c.Host), body, ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func aliasNow() func() time.Time {
+ return func() time.Time { return time.Unix(1755000000, 0) }
+}
+
+func TestReconcileAliasesMaterializes(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t, registry.Route{
+ Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready",
+ })
+ putClaim(t, s, Claim{
+ Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live",
+ })
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil {
+ t.Fatal(err)
+ }
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(aliases) != 1 {
+ t.Fatalf("got %d aliases, want 1: %+v", len(aliases), aliases)
+ }
+ a := aliases[0]
+ if a.Alias != "tickets.example.com" || a.Host != "canary/live" ||
+ a.Account != "bab" || a.App != "kass" || a.CreatedAt != 1755000000 {
+ t.Fatalf("alias row mismatch: %+v", a)
+ }
+}
+
+func TestReconcileAliasesOwnershipGate(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t, registry.Route{
+ Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready",
+ })
+ // Claim names the right route but the wrong app.
+ putClaim(t, s, Claim{
+ Host: "tickets.example.com", Account: "bab", App: "somethingelse", Route: "canary/live",
+ })
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil {
+ t.Fatal(err)
+ }
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(aliases) != 0 {
+ t.Fatalf("got %d aliases, want 0: %+v", len(aliases), aliases)
+ }
+}
+
+func TestReconcileAliasesOwnershipGateAccountMismatch(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ // Route's account is a VALID sqid ("bad") so release.RouteAccount
+ // returns it verbatim rather than falling back to OpsAccount — the
+ // mismatch must come from the comparison itself, not an invalid-account
+ // fallback masking it.
+ reg := aliasRegWith(t, registry.Route{
+ Host: "canary/live", App: "kass", Account: "bad", Kind: "instance", Status: "ready",
+ })
+ // Claim names the right route and the right app, but a different
+ // (also valid) account.
+ putClaim(t, s, Claim{
+ Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live",
+ })
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil {
+ t.Fatal(err)
+ }
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(aliases) != 0 {
+ t.Fatalf("got %d aliases, want 0 (account mismatch must refuse): %+v", len(aliases), aliases)
+ }
+}
+
+func TestReconcileAliasesRoutesWin(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t,
+ registry.Route{Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready"},
+ registry.Route{Host: "tickets.example.com", App: "other", Account: "bab", Kind: "instance", Status: "ready"},
+ )
+ putClaim(t, s, Claim{
+ Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live",
+ })
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil {
+ t.Fatal(err)
+ }
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(aliases) != 0 {
+ t.Fatalf("got %d aliases, want 0 (a real route owns that host): %+v", len(aliases), aliases)
+ }
+}
+
+func TestReconcileAliasesMissingRouteSkips(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t) // no routes at all — this box doesn't have the route
+ putClaim(t, s, Claim{
+ Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live",
+ })
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil {
+ t.Fatal(err)
+ }
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(aliases) != 0 {
+ t.Fatalf("got %d aliases, want 0: %+v", len(aliases), aliases)
+ }
+}
+
+func TestReconcileAliasesRemovesOrphans(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t, registry.Route{
+ Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready",
+ })
+ if err := reg.UpsertAlias(registry.RouteAlias{
+ Alias: "orphan.example.com", Host: "canary/live", Account: "bab", App: "kass",
+ CreatedAt: 1000,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ // No claim backs orphan.example.com.
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil {
+ t.Fatal(err)
+ }
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(aliases) != 0 {
+ t.Fatalf("got %d aliases, want 0 (orphan should have been removed): %+v", len(aliases), aliases)
+ }
+}
+
+func TestReconcileAliasesRouteless(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t, registry.Route{
+ Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready",
+ })
+ // A first-route claim (#82's case): no Route set yet.
+ putClaim(t, s, Claim{
+ Host: "tickets.example.com", Account: "bab", App: "kass",
+ })
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil {
+ t.Fatal(err)
+ }
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(aliases) != 0 {
+ t.Fatalf("got %d aliases, want 0 (routeless claim must be ignored): %+v", len(aliases), aliases)
+ }
+}
+
+func TestReconcileAliasesIdempotent(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t, registry.Route{
+ Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready",
+ })
+ putClaim(t, s, Claim{
+ Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live",
+ })
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil {
+ t.Fatal(err)
+ }
+ first, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(first) != 1 {
+ t.Fatalf("got %d aliases after first pass, want 1: %+v", len(first), first)
+ }
+ firstCreatedAt := first[0].CreatedAt
+
+ // Second pass, later clock: CreatedAt must be preserved, not bumped.
+ later := func() time.Time { return time.Unix(1755000000+3600, 0) }
+ if err := ReconcileAliases(context.Background(), s, reg, later); err != nil {
+ t.Fatal(err)
+ }
+ second, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(second) != 1 {
+ t.Fatalf("got %d aliases after second pass, want 1: %+v", len(second), second)
+ }
+ if second[0].CreatedAt != firstCreatedAt {
+ t.Fatalf("CreatedAt changed on re-run: first=%d second=%d", firstCreatedAt, second[0].CreatedAt)
+ }
+ if second[0].Alias != first[0].Alias || second[0].Host != first[0].Host ||
+ second[0].Account != first[0].Account || second[0].App != first[0].App {
+ t.Fatalf("alias row changed on re-run: first=%+v second=%+v", first[0], second[0])
+ }
+}
diff --git a/internal/domains/claims.go b/internal/domains/claims.go
new file mode 100644
index 000000000..7b00329c4
--- /dev/null
+++ b/internal/domains/claims.go
@@ -0,0 +1,251 @@
+// Global hostname claims — the desired-state object behind attaching an
+// extra hostname to a route (#151). A claim lives at ClaimKey(host), the
+// SAME key regardless of which account or app is asking, so the bucket key
+// itself is the cross-account uniqueness gate: two accounts racing for the
+// same host cannot both win, because there is only ever one object to Put.
+// Later tasks derive registry rows from these claims; nothing here talks to
+// the registry.
+package domains
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+ "regexp"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ClaimsPrefix is where every hostname claim lives.
+const ClaimsPrefix = "domains/"
+
+// ClaimKey is the bucket key for host's claim.
+func ClaimKey(host string) string { return ClaimsPrefix + host + ".json" }
+
+// Claim is the wire shape at ClaimKey(Host) — the global record of who owns
+// an extra hostname attached to a route. Route is omitempty because a claim
+// can exist ahead of the route it will back (reserved but not yet wired);
+// CreatedBy is omitempty because a claim minted by a box-side process, not a
+// human door, has no operator to name.
+type Claim struct {
+ Schema int `json:"schema"` // 1
+ Host string `json:"host"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Route string `json:"route,omitempty"`
+ CreatedAt string `json:"created_at"`
+ CreatedBy string `json:"created_by,omitempty"`
+ // Wildcard and Catchall are the woodstar-carloku branch's two claim
+ // capabilities (its migration spec §4-5 gives them meaning; nothing in
+ // this package acts on either). They are deliberately orthogonal:
+ // Wildcard opts the domain into a delegated-ACME wildcard cert (one
+ // *.<host> cert covering named aliases, no per-host CT-logged certs);
+ // Catchall derives the *.<host> catch-all alias entry to the primary
+ // route, and the attach surface refuses it without Wildcard (a
+ // catch-all nobody can serve TLS for is a config error, not a state).
+ // Both are declared here so a converge rewrite by this binary
+ // round-trips them instead of stripping fields it doesn't know — the
+ // additive-wire rule applied to writers, not just readers. Converge
+ // preserves only DECLARED fields (json.Unmarshal drops unknown ones),
+ // which is exactly why any future claim field must be added here in
+ // the same change that first writes it.
+ Wildcard bool `json:"wildcard,omitempty"`
+ Catchall bool `json:"catchall,omitempty"`
+}
+
+// ErrHostUnavailable is returned by AttachClaim when host is already claimed
+// by a different account or app. Deliberately neutral: it never names the
+// current holder, so a caller cannot use this door to discover who owns a
+// hostname they do not.
+var ErrHostUnavailable = errors.New("hostname unavailable")
+
+// getClaimETag reads and parses host's claim, carrying the etag it was read
+// under. Not-found stays os.ErrNotExist-wrapped. A schema newer than this
+// binary understands is refused rather than silently truncated by
+// json.Unmarshal — the GetManifestETag precedent (provision.go).
+func getClaimETag(ctx context.Context, s release.Store, host string) (*Claim, string, error) {
+ raw, etag, err := s.Get(ctx, ClaimKey(host))
+ if err != nil {
+ return nil, "", err
+ }
+ var c Claim
+ if err := json.Unmarshal(raw, &c); err != nil {
+ return nil, "", fmt.Errorf("domains: claim %s: %w", host, err)
+ }
+ if c.Schema > 1 {
+ return nil, "", fmt.Errorf("domains: claim %s: schema %d is newer than this binary understands — upgrade carlos", host, c.Schema)
+ }
+ return &c, etag, nil
+}
+
+// GetClaim reads host's claim. Not-found stays os.ErrNotExist-wrapped.
+func GetClaim(ctx context.Context, s release.Store, host string) (*Claim, error) {
+ c, _, err := getClaimETag(ctx, s, host)
+ return c, err
+}
+
+// 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.
+//
+// created reports that this call brought the claim into existence; changed
+// reports that it WROTE — a create or a route converge, and false for the
+// pure no-op where the stored claim already said what the caller asked for.
+// The two are separate answers because they drive different things: created
+// is what the door tells the caller, changed is what the door audits and
+// bumps control/serial on. Callers that re-attach on a loop (titogo's router
+// re-attaches every hostname on each reload pass) make no-op attaches the
+// steady state, and a serial that moved for a call that wrote nothing would
+// send every box in the fleet looking for a change that isn't there.
+func AttachClaim(ctx context.Context, s release.Store, c Claim) (created, changed bool, err error) {
+ c.Schema = 1
+ body, err := json.Marshal(c)
+ if err != nil {
+ return false, 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, 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, false, err
+ }
+ } else if err != nil {
+ return false, false, err
+ }
+ if cur.Account != c.Account || cur.App != c.App {
+ return false, false, ErrHostUnavailable
+ }
+ if cur.Route == c.Route {
+ return false, false, nil // nothing to write: this call is a no-op
+ }
+ // Converge repoints Route and nothing else: the stored claim keeps
+ // its CreatedAt (the claim's age) and Wildcard (TLS opt-in state a
+ // route repoint has no business clearing). Writing the caller's
+ // whole claim here would strip both.
+ next := *cur
+ next.Route = c.Route
+ nbody, err := json.Marshal(next)
+ if err != nil {
+ return false, false, err
+ }
+ if _, err := s.Put(ctx, ClaimKey(c.Host), nbody, etag); err == nil {
+ return false, true, nil // not a create, but a real write
+ } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 {
+ continue // converge race — one retry, then surface
+ } else {
+ return false, false, err
+ }
+ }
+}
+
+// DetachClaim removes host's claim, but only for the owner that holds it:
+// a mismatched account/app is quietly ignored (removed=false, nil error)
+// rather than erroring, the same neutrality AttachClaim's ErrHostUnavailable
+// observes — a caller must not learn who the real owner is by probing
+// detach. An already-absent claim is likewise quiet: detach is idempotent.
+func DetachClaim(ctx context.Context, s release.Store, host, account, app string) (bool, error) {
+ cur, err := GetClaim(ctx, s, host)
+ if errors.Is(err, os.ErrNotExist) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ if cur.Account != account || cur.App != app {
+ return false, nil
+ }
+ if err := s.Delete(ctx, ClaimKey(host)); err != nil {
+ return false, err
+ }
+ return true, nil
+}
+
+// ListClaims returns every valid claim in the bucket. An unparseable or
+// newer-schema object is logged and skipped — one bad claim must never take
+// the whole listing down.
+func ListClaims(ctx context.Context, s release.Store) ([]Claim, error) {
+ keys, err := s.List(ctx, ClaimsPrefix)
+ if err != nil {
+ return nil, fmt.Errorf("domains: list claims: %w", err)
+ }
+ var claims []Claim
+ for _, key := range keys {
+ raw, _, err := s.Get(ctx, key)
+ if err != nil {
+ log.Printf("domains: claim %s: %v", key, err)
+ continue
+ }
+ var c Claim
+ if err := json.Unmarshal(raw, &c); err != nil {
+ log.Printf("domains: claim %s: unparseable: %v", key, err)
+ continue
+ }
+ if c.Schema > 1 {
+ log.Printf("domains: claim %s: schema %d is newer than this binary understands, skipping", key, c.Schema)
+ continue
+ }
+ claims = append(claims, c)
+ }
+ return claims, nil
+}
+
+// hostLabelRe mirrors provision.labelRe (which itself mirrors
+// domains.accountNameRe — see that var's comment) — pinned HERE rather than
+// imported from internal/provision, because internal/provision/reconcile.go
+// already imports internal/domains for account/route materialization;
+// importing provision.ValidHostShape back would be a cycle. Same DNS label
+// rule: lowercase a-z, 0-9, hyphen, no leading/trailing hyphen, 1-63 chars.
+var hostLabelRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
+
+// validHostShape is provision.ValidHostShape's check, duplicated for the
+// import-cycle reason hostLabelRe explains: every label of host must match
+// hostLabelRe, and the whole host must fit in the DNS 253-character ceiling.
+func validHostShape(host string) bool {
+ if host == "" || len(host) > 253 {
+ return false
+ }
+ for _, label := range strings.Split(host, ".") {
+ if !hostLabelRe.MatchString(label) {
+ return false
+ }
+ }
+ return true
+}
+
+// NormalizeAliasHost lowercases raw, strips one trailing dot, and validates
+// the result: validHostShape (the same label rules a route host is held to)
+// AND at least one interior dot — a bare label like "localhost" is never a
+// public hostname, however it parses.
+func NormalizeAliasHost(raw string) (string, bool) {
+ h := strings.ToLower(raw)
+ h = strings.TrimSuffix(h, ".")
+ if !validHostShape(h) || !strings.Contains(h, ".") {
+ return "", false
+ }
+ return h, true
+}
+
+// HostDenied reports whether host equals any entry in deny, or is a
+// subdomain of one (ends in "."+entry). Empty entries are skipped, so a
+// deny list padded with "" (e.g. from a blank config line) can never match
+// every host.
+func HostDenied(host string, deny []string) bool {
+ for _, entry := range deny {
+ if entry == "" {
+ continue
+ }
+ if host == entry || strings.HasSuffix(host, "."+entry) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/domains/claims_test.go b/internal/domains/claims_test.go
new file mode 100644
index 000000000..078f10ed4
--- /dev/null
+++ b/internal/domains/claims_test.go
@@ -0,0 +1,256 @@
+package domains
+
+import (
+ "context"
+ "errors"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestAttachClaimCreates(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ created, changed, err := AttachClaim(ctx, s, Claim{
+ Host: "tickets.example.com", Account: "bab", App: "kass",
+ Route: "canary/live", CreatedAt: "2026-08-12T00:00:00Z", CreatedBy: "paul@tito.io",
+ })
+ if err != nil || !created || !changed {
+ t.Fatalf("AttachClaim: created=%v changed=%v err=%v, want true true nil", created, changed, err)
+ }
+ got, err := GetClaim(ctx, s, "tickets.example.com")
+ if err != nil {
+ t.Fatalf("GetClaim: %v", err)
+ }
+ if got.Schema != 1 || got.Host != "tickets.example.com" || got.Account != "bab" ||
+ got.App != "kass" || got.Route != "canary/live" || got.CreatedAt != "2026-08-12T00:00:00Z" ||
+ got.CreatedBy != "paul@tito.io" {
+ t.Fatalf("GetClaim round-trip mismatch: %+v", got)
+ }
+}
+
+func TestAttachClaimIdempotent(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live"}
+ if _, _, err := AttachClaim(ctx, s, claim); err != nil {
+ t.Fatal(err)
+ }
+ before, err := GetClaim(ctx, s, claim.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // changed=false is what makes this a NO-OP rather than a write: the door
+ // audits and bumps control/serial off that answer, and a re-attach on
+ // every router reload pass is the steady state, not the exception.
+ created, changed, err := AttachClaim(ctx, s, claim)
+ if err != nil || created || changed {
+ t.Fatalf("AttachClaim re-attach: created=%v changed=%v err=%v, want false false nil", created, changed, err)
+ }
+ after, err := GetClaim(ctx, s, claim.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if *before != *after {
+ t.Fatalf("object changed on idempotent re-attach: before=%+v after=%+v", before, after)
+ }
+}
+
+func TestAttachClaimConvergesRoute(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live",
+ CreatedAt: "2026-08-12T00:00:00Z", Wildcard: true, Catchall: true}
+ if _, _, err := AttachClaim(ctx, s, claim); err != nil {
+ t.Fatal(err)
+ }
+ // A converge repoints Route and nothing else: the caller's claim here
+ // deliberately carries no CreatedAt and no Wildcard, and both stored
+ // values must survive — a route repoint must never strip another
+ // branch's TLS opt-in or restamp the claim's age. created=false but
+ // changed=TRUE: a converge IS a store write, so the door must audit it
+ // and announce it to the fleet.
+ created, changed, err := AttachClaim(ctx, s, Claim{Host: claim.Host, Account: "bab", App: "kass", Route: "canary/next"})
+ if err != nil || created || !changed {
+ t.Fatalf("AttachClaim route change: created=%v changed=%v err=%v, want false true nil", created, changed, err)
+ }
+ got, err := GetClaim(ctx, s, claim.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Route != "canary/next" {
+ t.Fatalf("Route = %q, want canary/next", got.Route)
+ }
+ if !got.Wildcard || !got.Catchall || got.CreatedAt != "2026-08-12T00:00:00Z" {
+ t.Fatalf("converge must preserve Wildcard+Catchall+CreatedAt, got wildcard=%v catchall=%v created_at=%q",
+ got.Wildcard, got.Catchall, got.CreatedAt)
+ }
+}
+
+func TestAttachClaimOtherOwnerRefused(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live"}
+ if _, _, err := AttachClaim(ctx, s, claim); err != nil {
+ t.Fatal(err)
+ }
+ before, err := GetClaim(ctx, s, claim.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ other := claim
+ other.Account = "bad"
+ if _, _, err := AttachClaim(ctx, s, other); !errors.Is(err, ErrHostUnavailable) {
+ t.Fatalf("other account: err = %v, want ErrHostUnavailable", err)
+ }
+
+ otherApp := claim
+ otherApp.App = "console"
+ if _, _, err := AttachClaim(ctx, s, otherApp); !errors.Is(err, ErrHostUnavailable) {
+ t.Fatalf("other app: err = %v, want ErrHostUnavailable", err)
+ }
+
+ after, err := GetClaim(ctx, s, claim.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if *before != *after {
+ t.Fatalf("object changed on refused attach: before=%+v after=%+v", before, after)
+ }
+}
+
+func TestAttachClaimNewerSchemaRefused(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ body := []byte(`{"schema":2,"host":"tickets.example.com","account":"bab","app":"kass"}`)
+ if _, err := s.Put(ctx, ClaimKey("tickets.example.com"), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ _, _, err := AttachClaim(ctx, s, Claim{Host: "tickets.example.com", Account: "bab", App: "kass"})
+ if err == nil || !strings.Contains(err.Error(), "schema") {
+ t.Fatalf("AttachClaim over newer schema: err = %v, want error mentioning schema", err)
+ }
+ got, _, gerr := s.Get(ctx, ClaimKey("tickets.example.com"))
+ if gerr != nil {
+ t.Fatal(gerr)
+ }
+ if string(got) != string(body) {
+ t.Fatalf("object changed on newer-schema refusal: got %s", got)
+ }
+}
+
+func TestDetachClaimRemoves(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass"}
+ if _, _, err := AttachClaim(ctx, s, claim); err != nil {
+ t.Fatal(err)
+ }
+ removed, err := DetachClaim(ctx, s, claim.Host, claim.Account, claim.App)
+ if err != nil || !removed {
+ t.Fatalf("DetachClaim: removed=%v err=%v, want true nil", removed, err)
+ }
+ if _, err := GetClaim(ctx, s, claim.Host); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("GetClaim after detach: err = %v, want os.ErrNotExist", err)
+ }
+}
+
+func TestDetachClaimUnknownQuiet(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ removed, err := DetachClaim(ctx, s, "nope.example.com", "bab", "kass")
+ if err != nil || removed {
+ t.Fatalf("DetachClaim unknown: removed=%v err=%v, want false nil", removed, err)
+ }
+}
+
+func TestDetachClaimOtherOwnerQuiet(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass"}
+ if _, _, err := AttachClaim(ctx, s, claim); err != nil {
+ t.Fatal(err)
+ }
+ removed, err := DetachClaim(ctx, s, claim.Host, "bad", "console")
+ if err != nil || removed {
+ t.Fatalf("DetachClaim other owner: removed=%v err=%v, want false nil", removed, err)
+ }
+ if _, err := GetClaim(ctx, s, claim.Host); err != nil {
+ t.Fatalf("GetClaim after other-owner detach: %v, want claim still present", err)
+ }
+}
+
+func TestListClaimsSkipsGarbage(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if _, _, err := AttachClaim(ctx, s, Claim{Host: "a.example.com", Account: "bab", App: "kass"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := AttachClaim(ctx, s, Claim{Host: "b.example.com", Account: "bad", App: "console"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(ctx, ClaimKey("garbage.example.com"), []byte("not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ claims, err := ListClaims(ctx, s)
+ if err != nil {
+ t.Fatalf("ListClaims: %v", err)
+ }
+ if len(claims) != 2 {
+ t.Fatalf("ListClaims returned %d claims, want 2: %+v", len(claims), claims)
+ }
+ hosts := map[string]bool{}
+ for _, c := range claims {
+ hosts[c.Host] = true
+ }
+ if !hosts["a.example.com"] || !hosts["b.example.com"] {
+ t.Fatalf("ListClaims missing expected hosts: %+v", claims)
+ }
+}
+
+func TestNormalizeAliasHost(t *testing.T) {
+ cases := []struct {
+ raw string
+ want string
+ ok bool
+ }{
+ {"Tickets.Example.COM.", "tickets.example.com", true},
+ {"localhost", "", false},
+ {"a..b", "", false},
+ {"", "", false},
+ {strings.Repeat("a", 254), "", false},
+ }
+ for _, c := range cases {
+ got, ok := NormalizeAliasHost(c.raw)
+ if ok != c.ok {
+ t.Errorf("NormalizeAliasHost(%q) ok = %v, want %v", c.raw, ok, c.ok)
+ continue
+ }
+ if ok && got != c.want {
+ t.Errorf("NormalizeAliasHost(%q) = %q, want %q", c.raw, got, c.want)
+ }
+ }
+}
+
+func TestHostDenied(t *testing.T) {
+ deny := []string{"oncarlos.com"}
+ cases := []struct {
+ host string
+ want bool
+ }{
+ {"x.oncarlos.com", true},
+ {"oncarlos.com", true},
+ {"xoncarlos.com", false},
+ }
+ for _, c := range cases {
+ if got := HostDenied(c.host, deny); got != c.want {
+ t.Errorf("HostDenied(%q, %v) = %v, want %v", c.host, deny, got, c.want)
+ }
+ }
+ if HostDenied("anything.example.com", []string{"", "oncarlos.com"}) {
+ t.Fatal("empty deny entry must never match")
+ }
+}
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go
index f879b461e..c5d4f72f5 100644
--- a/internal/domains/reconcile.go
+++ b/internal/domains/reconcile.go
@@ -406,6 +406,19 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
}
routes = kept
+ // aliases are the extra hostnames route_aliases holds (Task 2/#151),
+ // health-stamped by sections 4 and 5 below exactly like the routes they
+ // point at. Listed ONCE, above both sections, and guarded the same way
+ // each section already guards itself — a box with neither Resolve nor
+ // AcmeDir configured pays no aliases query at all.
+ var aliases []registry.RouteAlias
+ if cfg.Resolve != nil || cfg.AcmeDir != "" {
+ aliases, err = reg.ListAliases()
+ if err != nil {
+ return fmt.Errorf("domains: list aliases: %w", err)
+ }
+ }
+
// --- 4. DNS health --------------------------------------------------------
if cfg.Resolve != nil {
edge := map[string]bool{}
@@ -414,20 +427,17 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
}
now := cfg.Now().Unix()
for _, rt := range routes {
- status := "" // resolver failure is UNKNOWN, never a guess
- if ips, err := cfg.Resolve(rt.Host); err == nil {
- status = "elsewhere"
- for _, ip := range ips {
- if edge[ip] {
- status = "pointing"
- break
- }
- }
- }
+ status := dnsStatus(cfg.Resolve, rt.Host, edge)
if err := reg.SetDNSStatus(rt.Host, status, now); err != nil {
log.Printf("domains: stamp dns on %s: %v", rt.Host, err)
}
}
+ for _, a := range aliases {
+ status := dnsStatus(cfg.Resolve, a.Alias, edge)
+ if err := reg.SetAliasDNSStatus(a.Alias, status, now); err != nil {
+ log.Printf("domains: stamp dns on alias %s: %v", a.Alias, err)
+ }
+ }
}
// --- 5. TLS health --------------------------------------------------------
@@ -438,10 +448,33 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
log.Printf("domains: stamp cert expiry on %s: %v", rt.Host, err)
}
}
+ for _, a := range aliases {
+ at := hostCertExpiry(cfg.AcmeDir, a.Alias, cfg.AppsDomain)
+ if err := reg.SetAliasCertExpiresAt(a.Alias, at); err != nil {
+ log.Printf("domains: stamp cert expiry on alias %s: %v", a.Alias, err)
+ }
+ }
}
return nil
}
+// dnsStatus resolves host through resolve and classifies the answer against
+// edge: a resolver failure is UNKNOWN ("") — never a guess — an edge-IP match
+// is "pointing", anything else is "elsewhere". Shared by the route and alias
+// loops in section 4 so the two verdicts can never drift apart.
+func dnsStatus(resolve func(host string) ([]string, error), host string, edge map[string]bool) string {
+ ips, err := resolve(host)
+ if err != nil {
+ return ""
+ }
+ for _, ip := range ips {
+ if edge[ip] {
+ return "pointing"
+ }
+ }
+ return "elsewhere"
+}
+
// OpsSqid is the built-in ops account's frozen sqid (registry id 1). Every
// deployment has it, console or not.
var OpsSqid = Sqid(1)
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go
index 4c25b21b0..ab3b8b652 100644
--- a/internal/domains/reconcile_test.go
+++ b/internal/domains/reconcile_test.go
@@ -624,6 +624,83 @@ func TestReconcileStampsCertExpiry(t *testing.T) {
}
}
+// aliasByName finds an alias row by hostname among reg.ListAliases(), the
+// same read-back-from-the-registry shape mustGet uses for routes.
+func aliasByName(t *testing.T, reg *registry.Registry, alias string) registry.RouteAlias {
+ t.Helper()
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, a := range aliases {
+ if a.Alias == alias {
+ return a
+ }
+ }
+ t.Fatalf("alias %s missing", alias)
+ return registry.RouteAlias{}
+}
+
+func TestReconcileStampsAliasDNS(t *testing.T) {
+ s, reg := dirStore(t), regWith(t,
+ registry.Route{Host: "app.test", Kind: "static", Status: "ready"},
+ )
+ for _, a := range []string{"points-alias.test", "elsewhere-alias.test", "broken-alias.test"} {
+ if err := reg.UpsertAlias(registry.RouteAlias{Alias: a, Host: "app.test"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ c := cfg(t)
+ c.Resolve = func(host string) ([]string, error) {
+ switch host {
+ case "points-alias.test":
+ return []string{"198.51.100.9", "203.0.113.7"}, nil // edge IP among others
+ case "elsewhere-alias.test":
+ return []string{"198.51.100.9"}, nil
+ default:
+ return nil, errors.New("NXDOMAIN or worse")
+ }
+ }
+ if err := domains.Reconcile(ctx, s, reg, c); err != nil {
+ t.Fatal(err)
+ }
+ now := c.Now().Unix()
+ for alias, want := range map[string]string{
+ "points-alias.test": "pointing",
+ "elsewhere-alias.test": "elsewhere",
+ "broken-alias.test": "", // resolver failure is UNKNOWN, never a guess
+ } {
+ got := aliasByName(t, reg, alias)
+ if got.DNSStatus != want {
+ t.Errorf("%s dns_status = %q, want %q", alias, got.DNSStatus, want)
+ }
+ if got.DNSCheckedAt != now {
+ t.Errorf("%s dns_checked_at = %d, want %d", alias, got.DNSCheckedAt, now)
+ }
+ }
+}
+
+func TestReconcileStampsAliasCert(t *testing.T) {
+ s, reg := dirStore(t), regWith(t,
+ registry.Route{Host: "kass.training", Kind: "instance", App: "kass",
+ Status: "ready", Addr: "127.0.0.1:9000"},
+ )
+ if err := reg.UpsertAlias(registry.RouteAlias{Alias: "kass-alias.training", Host: "kass.training"}); err != nil {
+ t.Fatal(err)
+ }
+ acmeDir := t.TempDir()
+ notAfter := time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC)
+ writeCertBundle(t, acmeDir, "kass-alias.training", notAfter)
+ c := cfg(t)
+ c.AcmeDir = acmeDir
+ if err := domains.Reconcile(ctx, s, reg, c); err != nil {
+ t.Fatal(err)
+ }
+ if got := aliasByName(t, reg, "kass-alias.training"); got.CertExpiresAt != notAfter.Unix() {
+ t.Errorf("kass-alias.training cert_expires_at = %d, want %d", got.CertExpiresAt, notAfter.Unix())
+ }
+}
+
func TestReconcileInertWithoutAppsDomain(t *testing.T) {
s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance",
App: "kass", Status: "ready", Addr: "127.0.0.1:9000"})
diff --git a/internal/edge/alias_test.go b/internal/edge/alias_test.go
new file mode 100644
index 000000000..0951bc54f
--- /dev/null
+++ b/internal/edge/alias_test.go
@@ -0,0 +1,277 @@
+package edge
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// upsertAlias writes one route_aliases row, the shape the reconcile pass
+// (internal/domains) derives from a claim record. The edge only ever reads
+// Alias and Host; account/app are carried for realism.
+func upsertAlias(t *testing.T, reg *registry.Registry, alias, host string) {
+ t.Helper()
+ if err := reg.UpsertAlias(registry.RouteAlias{
+ Alias: alias, Host: host, Account: "acct", App: "app", CreatedAt: 1,
+ }); err != nil {
+ t.Fatalf("upsert alias %s -> %s: %v", alias, host, err)
+ }
+}
+
+// TestAliasServesPrimaryBackend is the feature itself: a hostname attached to
+// a ready route reaches that route's backend, with the customer's own Host
+// header preserved end to end (the app must see the name the browser asked
+// for, not the platform hostname).
+func TestAliasServesPrimaryBackend(t *testing.T) {
+ backend, seenHost := hostEchoBackend(t, "hello from the primary")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "app.acct.oncarlos.com", Kind: "service",
+ Addr: backend.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ upsertAlias(t, reg, "www.customer.example", "app.acct.oncarlos.com")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ srv := httptest.NewServer(e)
+ defer srv.Close()
+
+ code, body := getHost(t, srv.URL, "www.customer.example")
+ if code != http.StatusOK {
+ t.Fatalf("status = %d, want 200 for an attached hostname", code)
+ }
+ if body != "hello from the primary" {
+ t.Fatalf("body = %q, want the primary route's backend body", body)
+ }
+ if *seenHost != "www.customer.example" {
+ t.Fatalf("backend saw Host %q, want www.customer.example (inbound Host must be preserved)", *seenHost)
+ }
+}
+
+// TestAliasSharesUpstreamEntry is the composition pin (#152, multi-upstream
+// HA): the alias must map to the primary's OWN entry — the identical proxy
+// handler value and the primary's route row — never a proxy rebuilt for the
+// alias hostname. Everything downstream is keyed off route.Host (activator
+// entry, lease, live symlink, cgroup) and off the proxy value's per-primary
+// state, so a second handler would silently fork both.
+func TestAliasSharesUpstreamEntry(t *testing.T) {
+ backend, _ := hostEchoBackend(t, "primary")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "app.acct.oncarlos.com", Kind: "service",
+ Addr: backend.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ upsertAlias(t, reg, "www.customer.example", "app.acct.oncarlos.com")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ m := e.current()
+ primary, ok := m["app.acct.oncarlos.com"]
+ if !ok {
+ t.Fatalf("primary route missing from the upstream table")
+ }
+ alias, ok := m["www.customer.example"]
+ if !ok {
+ t.Fatalf("alias missing from the upstream table")
+ }
+ // Identity, not equivalent behaviour: the route carries no Version, so the
+ // handler is the *httputil.ReverseProxy pointer itself and == compares the
+ // pointer, which is exactly the claim being pinned.
+ if alias.proxy != primary.proxy {
+ t.Fatalf("alias proxy %p is not the primary's proxy %p — an alias must never get a rebuilt handler", alias.proxy, primary.proxy)
+ }
+ if alias.route.Host != "app.acct.oncarlos.com" {
+ t.Fatalf("alias route.Host = %q, want the PRIMARY's host (activator/version/purge keying all read it)", alias.route.Host)
+ }
+ if !alias.alias {
+ t.Fatalf("alias entry not marked alias — wildcard parent derivation would treat a customer domain as a platform host")
+ }
+ if primary.alias {
+ t.Fatalf("primary entry marked alias")
+ }
+}
+
+// TestAliasAllowed covers the autocert HostPolicy: an attached hostname is a
+// host this box serves, so it may be certed; a stranger's SNI may not.
+func TestAliasAllowed(t *testing.T) {
+ backend, _ := hostEchoBackend(t, "primary")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "app.acct.oncarlos.com", Kind: "service",
+ Addr: backend.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ upsertAlias(t, reg, "www.customer.example", "app.acct.oncarlos.com")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if !e.Allowed("www.customer.example") {
+ t.Fatalf("Allowed(www.customer.example) = false, want true for an attached hostname")
+ }
+ if e.Allowed("stranger.example") {
+ t.Fatalf("Allowed(stranger.example) = true, want false")
+ }
+}
+
+// TestAliasNeverShadowsRoute: routes always win. An alias row whose hostname
+// collides with a real route must not displace it — the route's own backend
+// keeps answering.
+func TestAliasNeverShadowsRoute(t *testing.T) {
+ one, _ := hostEchoBackend(t, "one")
+ two, _ := hostEchoBackend(t, "two")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "one.example.com", Kind: "service",
+ Addr: one.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert one: %v", err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "two.example.com", Kind: "service",
+ Addr: two.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert two: %v", err)
+ }
+ // An alias claiming a hostname that is already a route of its own.
+ upsertAlias(t, reg, "two.example.com", "one.example.com")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ srv := httptest.NewServer(e)
+ defer srv.Close()
+
+ code, body := getHost(t, srv.URL, "two.example.com")
+ if code != http.StatusOK || body != "two" {
+ t.Fatalf("got %d %q, want 200 \"two\" — a real route must win a collision with an alias", code, body)
+ }
+ if up := e.current()["two.example.com"]; up.alias {
+ t.Fatalf("route entry for two.example.com was overwritten by the alias")
+ }
+}
+
+// TestAliasSkipsMissingPrimary: an alias whose primary is not in the table
+// (not ready here, or not local to this box) is skipped entirely rather than
+// mapped to nothing — it must 404 as an unknown host and must not be certed.
+func TestAliasSkipsMissingPrimary(t *testing.T) {
+ backend, _ := hostEchoBackend(t, "should never be reached")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "pending.acct.oncarlos.com", Kind: "service",
+ Addr: backend.Listener.Addr().String(), Status: "pending",
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ upsertAlias(t, reg, "www.customer.example", "pending.acct.oncarlos.com")
+ // And one pointing at a host with no route row at all.
+ upsertAlias(t, reg, "gone.customer.example", "nosuch.acct.oncarlos.com")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ srv := httptest.NewServer(e)
+ defer srv.Close()
+
+ for _, host := range []string{"www.customer.example", "gone.customer.example"} {
+ if code, _ := getHost(t, srv.URL, host); code != http.StatusNotFound {
+ t.Fatalf("status for %s = %d, want 404 (alias with no ready primary)", host, code)
+ }
+ if e.Allowed(host) {
+ t.Fatalf("Allowed(%s) = true, want false — no certificate for an alias to nowhere", host)
+ }
+ }
+}
+
+// routeRecordingActivator records the route each wake was handed, so a test
+// can prove which host the activator was keyed on.
+type routeRecordingActivator struct {
+ routes []registry.Route
+}
+
+func (a *routeRecordingActivator) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool {
+ a.routes = append(a.routes, rt)
+ proxy.ServeHTTP(w, r) // "wake" is instant; proxy straight through
+ return true
+}
+
+func (a *routeRecordingActivator) RetireExcept(keep map[string]bool) {}
+
+// TestAliasWakesPrimaryOnce is the #129-class regression pin: a request to an
+// attached hostname wakes the PRIMARY's single activator entry. The activator
+// keys its entry, lease, live symlink and cgroup on route.Host, so an alias
+// arriving with its own host would start a second copy of the app beside the
+// first and fight it for the socket.
+func TestAliasWakesPrimaryOnce(t *testing.T) {
+ backend, _ := hostEchoBackend(t, "woken")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "hib.acct.oncarlos.com", Kind: "instance",
+ Addr: backend.Listener.Addr().String(), Status: "ready", Hibernate: true,
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ upsertAlias(t, reg, "www.customer.example", "hib.acct.oncarlos.com")
+
+ act := &routeRecordingActivator{}
+ e, err := New(reg, act)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ srv := httptest.NewServer(e)
+ defer srv.Close()
+
+ code, body := getHost(t, srv.URL, "www.customer.example")
+ if code != http.StatusOK || body != "woken" {
+ t.Fatalf("got %d %q, want 200 woken", code, body)
+ }
+ if len(act.routes) != 1 {
+ t.Fatalf("activator saw %d wakes, want exactly 1", len(act.routes))
+ }
+ if got := act.routes[0].Host; got != "hib.acct.oncarlos.com" {
+ t.Fatalf("activator was handed route.Host = %q, want the primary hib.acct.oncarlos.com", got)
+ }
+}
+
+// TestCurrentHostsSkipsAliases: wildcard parent derivation reads currentHosts,
+// so a customer hostname riding a platform-minted primary must never appear
+// there — otherwise a name like x.y.<apps-domain> could mint a wildcard parent
+// and burn an ACME issuance on a domain the wildcard never covered.
+func TestCurrentHostsSkipsAliases(t *testing.T) {
+ backend, _ := hostEchoBackend(t, "primary")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "app.acct.oncarlos.com", Kind: "service",
+ Addr: backend.Listener.Addr().String(), Status: "ready", Platform: true,
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ upsertAlias(t, reg, "shop.acct.oncarlos.com", "app.acct.oncarlos.com")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ got := e.currentHosts()
+ if !slices.Contains(got, "app.acct.oncarlos.com") {
+ t.Fatalf("currentHosts = %v, want it to contain the platform-minted primary", got)
+ }
+ if slices.Contains(got, "shop.acct.oncarlos.com") {
+ t.Fatalf("currentHosts = %v, must NOT contain an attached hostname", got)
+ }
+}
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 16cbeaae6..7bd670660 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -150,6 +150,12 @@ type upstream struct {
// whether its process is live (it's the edge's child), so "dead backend"
// detection is the activator's state table, not a failed dial.
hibernating bool
+ // 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
}
// New builds an Edge over reg and performs the startup reload. act may be nil
@@ -418,6 +424,29 @@ func (e *Edge) Reload() error {
}
m[t.Host] = upstream{proxy: proxy, route: t, hibernating: hib}
}
+ // 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
+ }
// Fleet channel auth: registry-backed, so this refreshes on the SAME poll
// that rebuilds routes — Task 1's Fingerprint moves on every fleet
// mutation (including token rotation), and the edge's existing 2s
@@ -488,11 +517,16 @@ func (e *Edge) current() map[string]upstream {
// never meant to cover. Platform-minted app routes set Platform:true; static
// parents (e.g. tito's go.tito.io) come from CARLOS_WILDCARD_DOMAINS via env,
// so they are unaffected by this filter.
+//
+// Attached hostnames (#151) are excluded by the !up.alias half: an alias
+// entry carries the PRIMARY's route row verbatim, Platform flag included, so
+// without the flag a customer domain would inherit its platform primary's
+// trust and mint a parent for a zone the platform doesn't own.
func (e *Edge) currentHosts() []string {
m := e.current()
out := make([]string, 0, len(m))
for h, up := range m {
- if up.route.Platform {
+ if up.route.Platform && !up.alias {
out = append(out, h)
}
}
diff --git a/internal/edge/storedoor.go b/internal/edge/storedoor.go
index f96443f79..ad3a0d460 100644
--- a/internal/edge/storedoor.go
+++ b/internal/edge/storedoor.go
@@ -362,13 +362,24 @@ func canonicalPrefix(raw string) (string, bool) {
// the box (knownAccounts -> ParseRecordKey)
// apps/<sqid>/** its owning account's whole app estate
// fleets/<fleet>/** its own corner
+// domains/** every tenant's hostname claims (see below)
//
-// The last line is not in F3's enumeration and is not a widening of it: the
-// fleet's own prefix is the only place it may WRITE, and every write on this
-// wire is a CAS (read the etag, then Put with If-Match). A write scope you
-// cannot read is a write scope you cannot use — the binding takeover guard
-// and the heartbeat both read their own object first. It grants access to
-// nothing beyond what aclWrite already grants.
+// The fleets/<fleet>/** line is not in F3's enumeration and is not a
+// widening of it: the fleet's own prefix is the only place it may WRITE, and
+// every write on this wire is a CAS (read the etag, then Put with If-Match).
+// A write scope you cannot read is a write scope you cannot use — the
+// binding takeover guard and the heartbeat both read their own object first.
+// It grants access to nothing beyond what aclWrite already grants.
+//
+// domains/ is different in kind, not degree (#151): a claim record
+// (domains.Claim) is a hostname→account/app/route binding, and that binding
+// is cross-tenant-readable BY DESIGN — it is exactly the information DNS and
+// the serving edge already make public to anyone who resolves the hostname,
+// so scoping it to sqid or fleet would hide nothing and would only break
+// ReconcileAliases, the alias pass every box runs to reconcile its own
+// routes' extra hostnames against the global claim set. Write stays
+// console-only: aclWrite never answers true for a domains/ key, so this read
+// grant cannot be used to mint or steal a claim.
func aclRead(sqid, fleet, key string) bool {
if sqid == "" || fleet == "" {
return false
@@ -377,6 +388,9 @@ func aclRead(sqid, fleet, key string) bool {
case "control/serial", "bootstrap/carlos":
return true
}
+ if strings.HasPrefix(key, "domains/") {
+ return true
+ }
return strings.HasPrefix(key, "accounts/"+sqid+"/") ||
strings.HasPrefix(key, "apps/"+sqid+"/") ||
strings.HasPrefix(key, "fleets/"+fleet+"/")
@@ -400,6 +414,15 @@ func aclDir(sqid, fleet, dir string) bool {
if dir == "accounts/"+sqid+"/" {
return true
}
+ // domains/ (#151): cross-tenant-readable by the same reasoning as
+ // aclRead, so every child prefix under it is in scope regardless of
+ // tenant. ListClaims lists domains/ flat today (List, not ListDirs), so
+ // this branch is not exercised in production yet — it exists so a future
+ // nested claim layout does not silently lose read access the way
+ // accounts/ almost did.
+ if strings.HasPrefix(dir, "domains/") {
+ return true
+ }
return strings.HasPrefix(dir, "apps/"+sqid+"/") ||
strings.HasPrefix(dir, "fleets/"+fleet+"/")
}
@@ -412,7 +435,11 @@ func aclDir(sqid, fleet, dir string) bool {
//
// It can never write a release artifact, a channel pointer, or an instance
// record, which is the tamper surface the whole spec exists to close (F6:
-// retention does not arm on a fleet box either).
+// retention does not arm on a fleet box either). It can never write a
+// domains/ claim either (#151): aclRead's cross-tenant grant there is
+// read-only by design — a claim is a console-mediated hostname reservation,
+// and a box that could write one could mint or steal any hostname on the
+// platform, not just its own account's.
//
// The logs scope is SEGMENT-EXACT on purpose, and the exactness is the
// security property: the grant is apps/<sqid>/<ONE app segment>/logs/**, never
@@ -495,6 +522,15 @@ func aclListScope(sqid, fleet, prefix string) (string, bool) {
return "", false
case prefix == "accounts" || prefix == "accounts/":
return "accounts/", true
+ case prefix == "domains" || prefix == "domains/":
+ // #151: the OTHER shared scope, and shared for the same reason as
+ // accounts/ above but simpler — every claim is cross-tenant-readable
+ // (aclRead), so unlike apps/ and fleets/ there is no per-tenant
+ // subtree to narrow into. ReconcileAliases always lists the whole
+ // prefix; a narrower ask (e.g. "domains/x.example.json") is refused
+ // rather than widened, matching accounts/'s deliberate refusal of
+ // "accounts/<sqid>/" — see that case's comment.
+ return "domains/", true
case prefix == "fleets" || prefix == "fleets/" || prefix == "fleets/"+fleet:
return ownFleet, true
case strings.HasPrefix(prefix, ownFleet):
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go
index a2eb8011d..3a0df9c7a 100644
--- a/internal/edge/storedoor_test.go
+++ b/internal/edge/storedoor_test.go
@@ -142,6 +142,14 @@ func TestStoreDoorACLMatrix(t *testing.T) {
{"fleets/pix/status/boxes/box-a.json", opWrite, false, "fleet prefix must not match a longer fleet name"},
{"fleets/pi", opWrite, false, "the fleet prefix itself is not an object"},
+ // --- domains/ claims (#151): cross-tenant-readable by design, the
+ // alias pass (ReconcileAliases) reads them on every box; console-only
+ // write ---
+ {"domains/app.example.com.json", opRead, true, "hostname claim, own or any other tenant's"},
+ {"domains/app.example.com.json", opWrite, false, "boxes read claims, only the console writes them"},
+ {"domains/app.example.com.json", opDelete, false, "nor delete them"},
+ {"domains", opRead, false, "an object AT the bare name, not the claims prefix"},
+
// --- shapes canonicalKey must kill before the ACL is consulted ---
{"apps/ab12/../zz99/web/channels/stable.json", opRead, false, "traversal out of own account"},
{"fleets/pi/../other/status/boxes/box-a.json", opWrite, false, "traversal out of own fleet"},
@@ -231,6 +239,12 @@ func TestACLListScope(t *testing.T) {
// this pin makes visible.
{"accounts/" + testSqid + "/", "", false},
{"accounts/" + testSqid, "", false},
+ // domains/ (#151): the OTHER shared scope — every claim is
+ // cross-tenant-readable, so (unlike apps/ and fleets/) there is no
+ // per-tenant subtree to narrow into; only the bare prefix maps.
+ {"domains/", "domains/", true},
+ {"domains", "domains/", true},
+ {"domains/app.example.com.json", "", false},
{"fleets/", "fleets/pi/", true},
{"fleets/pi/status/boxes/", "fleets/pi/status/boxes/", true},
{"fleets/other/", "", false},
@@ -264,6 +278,12 @@ func TestACLDirIsPerTenant(t *testing.T) {
"fleets/other/status/": false,
"fleets/" + testFleet + "x/y/": false,
"control/": false,
+ // domains/ (#151): cross-tenant by design, so no tenant scoping to
+ // pin here — every child prefix under it is in scope. ListClaims
+ // lists domains/ flat (List, not ListDirs) today, so this path isn't
+ // exercised in production; the row exists so a future nested claim
+ // layout doesn't silently lose read access.
+ "domains/anything/": true,
}
for dir, want := range cases {
if got := aclDir(testSqid, testFleet, dir); got != want {
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 4b6c310f8..b08dc9a83 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -1115,13 +1115,16 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service) {
reg: reg, store: store, pub: pub, optDir: optDir,
restart: restart, passOpts: opts,
// The serial-triggered full pass set, in spec order: mint routes
- // (reconcile), then stage them (adopt), then deliver their config.
- // Each error is one log line, never fatal — the hourly floors stand
- // underneath every leg.
+ // (reconcile), then reconcile aliases, then stage them (adopt), then deliver
+ // their config. Each error is one log line, never fatal — the hourly floors
+ // stand underneath every leg.
fullPass: func(ctx context.Context) {
if err := reconcileSerialized(ctx, store, reg, provisionTickConfig(os.Getenv)); err != nil {
log.Printf("agent: fast converge reconcile: %v", err)
}
+ if err := domains.ReconcileAliases(ctx, store, reg, time.Now); err != nil {
+ log.Printf("agent: fast converge alias pass: %v", err)
+ }
if err := release.Pass(ctx, reg, store, pub, optDir, restart, opts...); err != nil {
log.Printf("agent: fast converge adopt pass: %v", err)
}
@@ -1467,6 +1470,9 @@ func instancesLoop(reg *registry.Registry) {
if err := reconcileSerialized(ctx, store, reg, cfg); err != nil {
log.Printf("agent: instances pass: %v", err)
}
+ if err := domains.ReconcileAliases(ctx, store, reg, time.Now); err != nil {
+ log.Printf("agent: instances alias pass: %v", err)
+ }
if !isFleetBox {
if err := ReconcileFleetBindings(ctx, store, reg); err != nil {
log.Printf("agent: fleet bindings reconcile: %v", err)
@@ -1831,6 +1837,18 @@ func statusTick(ctx context.Context, s release.Store, reg *registry.Registry, p
}
now := time.Now()
+ // Aliases carries route_aliases' DNS/cert stamps (Task 2/5) up to the box
+ // snapshot (issue #151) — read fresh alongside routes above rather than
+ // folded into the caller's Probes in statusLoop, since reg is already in
+ // scope here and a failed read must not sink the whole snapshot: logged
+ // and left nil (CollectBox already treats nil the same as "no alias rows
+ // exist"), same best-effort shape as every other probe in this loop.
+ if aliases, err := reg.ListAliases(); err != nil {
+ log.Printf("agent: status tick: list aliases: %v", err)
+ } else {
+ p.Aliases = aliases
+ }
+
box := status.CollectBox(routes, p, now)
if labelOverride != "" {
box.Label = labelOverride
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index a0778d0b8..124ce77a6 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -333,6 +333,22 @@ CREATE TABLE IF NOT EXISTS routes (
return err
}
}
+ // route_aliases is a derived table (a later reconcile pass owns every
+ // row, materializing domains.Claim records into it) — a plain CREATE
+ // TABLE IF NOT EXISTS, no legacy shape to carry forward.
+ if _, err := r.sql.Exec(`
+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
+);`); err != nil {
+ return err
+ }
if err := r.migrateAccounts(); err != nil {
return err
}
@@ -1167,9 +1183,88 @@ WHERE kind = 'instance' AND next_due > 0 AND next_due <= ? ORDER BY host`, now)
return out, rows.Err()
}
-// Fingerprint summarizes the routes, fleets and fleet_boxes tables over a
-// fresh connection, for the edge's cheap change poll. It is content-accurate:
-// it hashes the actual VALUES of the reload-relevant columns, not a
+// RouteAlias is one derived row of the route_aliases table: an extra
+// hostname that resolves to an existing route's host, materialized from a
+// domains.Claim by a later reconcile pass (this package owns the storage
+// shape only — nothing here decides which claims win). Alias is the extra
+// hostname being attached; Host is the route it points at. DNSStatus,
+// DNSCheckedAt and CertExpiresAt are box-stamped health state, same shape as
+// the identically-named columns on routes, and are deliberately excluded
+// from Fingerprint (see there) so a stamping pass alone never forces an edge
+// reload.
+type RouteAlias struct {
+ Alias string
+ Host string
+ Account string
+ App string
+ DNSStatus string
+ DNSCheckedAt int64
+ CertExpiresAt int64
+ CreatedAt int64
+}
+
+// ListAliases returns every route alias, ordered by alias.
+func (r *Registry) ListAliases() ([]RouteAlias, error) {
+ rows, err := r.sql.Query(`SELECT alias, host, account, app, dns_status,
+dns_checked_at, cert_expires_at, created_at FROM route_aliases ORDER BY alias`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []RouteAlias
+ for rows.Next() {
+ var a RouteAlias
+ if err := rows.Scan(&a.Alias, &a.Host, &a.Account, &a.App, &a.DNSStatus,
+ &a.DNSCheckedAt, &a.CertExpiresAt, &a.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, a)
+ }
+ return out, rows.Err()
+}
+
+// UpsertAlias inserts or updates an alias row by its hostname. Only host,
+// account and app are overwritten on conflict — the DNS/cert stamps and
+// created_at are reconcile-pass- and box-stamp-owned state that a repoint
+// must not reset out from under the passes that maintain them (the same
+// preserved-column shape Upsert uses for routes, applied here by simply
+// leaving those columns out of the SET list rather than a CASE guard, since
+// every writer of this table already knows it must not carry stamps).
+func (r *Registry) UpsertAlias(a RouteAlias) error {
+ _, err := r.sql.Exec(`
+INSERT INTO route_aliases (alias, host, account, app, dns_status, dns_checked_at, cert_expires_at, created_at)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ON CONFLICT(alias) DO UPDATE SET host=excluded.host, account=excluded.account, app=excluded.app`,
+ a.Alias, a.Host, a.Account, a.App, a.DNSStatus, a.DNSCheckedAt, a.CertExpiresAt, a.CreatedAt)
+ return err
+}
+
+// DeleteAlias removes an alias by its hostname. Idempotent: an alias already
+// gone is a success, same contract as Delete on routes.
+func (r *Registry) DeleteAlias(alias string) error {
+ _, err := r.sql.Exec(`DELETE FROM route_aliases WHERE alias = ?`, alias)
+ return err
+}
+
+// SetAliasDNSStatus stamps an alias's DNS health verdict + check time,
+// narrowly (same rationale as SetDNSStatus on routes: it lands on its own
+// cadence and must not race a concurrent UpsertAlias repoint).
+func (r *Registry) SetAliasDNSStatus(alias, status string, at int64) error {
+ _, err := r.sql.Exec(`UPDATE route_aliases SET dns_status = ?, dns_checked_at = ? WHERE alias = ?`, status, at, alias)
+ return err
+}
+
+// SetAliasCertExpiresAt stamps an alias's cached-cert NotAfter, narrowly.
+// 0 = unknown (same rationale as SetCertExpiresAt on routes).
+func (r *Registry) SetAliasCertExpiresAt(alias string, at int64) error {
+ _, err := r.sql.Exec(`UPDATE route_aliases SET cert_expires_at = ? WHERE alias = ?`, at, alias)
+ return err
+}
+
+// Fingerprint summarizes the routes, fleets, fleet_boxes and route_aliases
+// tables over a fresh connection, for the edge's cheap change poll. It is
+// content-accurate: it hashes the actual VALUES of the reload-relevant
+// columns, not a
// length-weighted aggregate, so two DIFFERENT values of the SAME length
// (channel canary/1 -> canary/2, a git short-sha version bump, a rotated
// sha256 token hash, a failover restamp box-a -> box-b) change the result
@@ -1293,5 +1388,28 @@ FROM fleet_boxes ORDER BY fleet, label`)
return "", err
}
+ // route_aliases: only alias/host/account/app decide which hostnames the
+ // edge routes and where. dns_status/dns_checked_at/cert_expires_at are
+ // deliberately excluded here for the same reason the routes SELECT above
+ // omits its own dns_status/cert_expires_at columns — a box-side pass
+ // stamps them on a 5-minute cadence, and hashing a value that moves on
+ // that cadence would force a pointless fleet-wide edge reload every pass.
+ fmt.Fprint(h, "\x1droute_aliases\x1e")
+ arows, err := db.Query(`SELECT alias, host, account, app FROM route_aliases ORDER BY alias`)
+ if err != nil {
+ return "", err
+ }
+ defer arows.Close()
+ for arows.Next() {
+ var alias, host, account, app string
+ if err := arows.Scan(&alias, &host, &account, &app); err != nil {
+ return "", err
+ }
+ fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1e", alias, host, account, app)
+ }
+ if err := arows.Err(); err != nil {
+ return "", err
+ }
+
return fmt.Sprintf("%d:%x", n, h.Sum(nil)), nil
}
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index c537e3415..0f8717b7b 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -1834,3 +1834,189 @@ func TestSetPlacementUnknownHostErrors(t *testing.T) {
t.Fatal("SetPlacement on an unknown host: got nil error, want one")
}
}
+
+// TestRouteAliasCRUD pins UpsertAlias/ListAliases/DeleteAlias's contract: a
+// re-upsert repoints host/account/app but must NOT reset the DNS/cert stamps
+// or created_at — those are reconcile-pass-owned and box-stamped state, the
+// same shape as Upsert's preserved columns on routes.
+func TestRouteAliasCRUD(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.UpsertAlias(RouteAlias{
+ Alias: "extra.example.com", Host: "app.example.com",
+ Account: "bab", App: "myapp", CreatedAt: 1000,
+ }); err != nil {
+ t.Fatalf("UpsertAlias: %v", err)
+ }
+ got, err := reg.ListAliases()
+ if err != nil {
+ t.Fatalf("ListAliases: %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("ListAliases = %+v, want exactly one row", got)
+ }
+ if got[0].Alias != "extra.example.com" || got[0].Host != "app.example.com" ||
+ got[0].Account != "bab" || got[0].App != "myapp" || got[0].CreatedAt != 1000 {
+ t.Fatalf("round-tripped alias = %+v, want the upserted values", got[0])
+ }
+
+ // Stamp the row the way a box-side pass would, THEN re-upsert a repoint —
+ // the stamps and created_at must survive.
+ if err := reg.SetAliasDNSStatus("extra.example.com", "pointing", 2000); err != nil {
+ t.Fatalf("SetAliasDNSStatus: %v", err)
+ }
+ if err := reg.SetAliasCertExpiresAt("extra.example.com", 3000); err != nil {
+ t.Fatalf("SetAliasCertExpiresAt: %v", err)
+ }
+ if err := reg.UpsertAlias(RouteAlias{
+ Alias: "extra.example.com", Host: "other.example.com",
+ Account: "bad", App: "otherapp", CreatedAt: 9999,
+ }); err != nil {
+ t.Fatalf("re-UpsertAlias: %v", err)
+ }
+ got, err = reg.ListAliases()
+ if err != nil {
+ t.Fatalf("ListAliases: %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("ListAliases after re-upsert = %+v, want exactly one row", got)
+ }
+ a := got[0]
+ if a.Host != "other.example.com" || a.Account != "bad" || a.App != "otherapp" {
+ t.Fatalf("re-upsert did not repoint host/account/app: %+v", a)
+ }
+ if a.DNSStatus != "pointing" || a.DNSCheckedAt != 2000 || a.CertExpiresAt != 3000 {
+ t.Fatalf("re-upsert clobbered stamps: %+v", a)
+ }
+ if a.CreatedAt != 1000 {
+ t.Fatalf("re-upsert clobbered created_at: got %d, want the FIRST insert's 1000", a.CreatedAt)
+ }
+
+ // Delete is idempotent: gone is success, called twice is still success.
+ if err := reg.DeleteAlias("extra.example.com"); err != nil {
+ t.Fatalf("DeleteAlias: %v", err)
+ }
+ if err := reg.DeleteAlias("extra.example.com"); err != nil {
+ t.Fatalf("DeleteAlias (already gone): %v", err)
+ }
+ got, err = reg.ListAliases()
+ if err != nil {
+ t.Fatalf("ListAliases: %v", err)
+ }
+ if len(got) != 0 {
+ t.Fatalf("ListAliases after delete = %+v, want none", got)
+ }
+}
+
+// TestRouteAliasStamps pins SetAliasDNSStatus/SetAliasCertExpiresAt as narrow
+// UPDATEs that persist independently of UpsertAlias — same rationale as
+// SetDNSStatus/SetCertExpiresAt on routes.
+func TestRouteAliasStamps(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.UpsertAlias(RouteAlias{Alias: "extra.example.com", Host: "app.example.com"}); err != nil {
+ t.Fatalf("UpsertAlias: %v", err)
+ }
+ if err := reg.SetAliasDNSStatus("extra.example.com", "elsewhere", 111); err != nil {
+ t.Fatalf("SetAliasDNSStatus: %v", err)
+ }
+ if err := reg.SetAliasCertExpiresAt("extra.example.com", 222); err != nil {
+ t.Fatalf("SetAliasCertExpiresAt: %v", err)
+ }
+ got, err := reg.ListAliases()
+ if err != nil {
+ t.Fatalf("ListAliases: %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("ListAliases = %+v, want exactly one row", got)
+ }
+ if got[0].DNSStatus != "elsewhere" || got[0].DNSCheckedAt != 111 || got[0].CertExpiresAt != 222 {
+ t.Fatalf("stamps did not persist: %+v", got[0])
+ }
+
+ // A same-host UpsertAlias (no repoint) must not disturb the stamps either.
+ if err := reg.UpsertAlias(RouteAlias{Alias: "extra.example.com", Host: "app.example.com"}); err != nil {
+ t.Fatalf("UpsertAlias: %v", err)
+ }
+ got, err = reg.ListAliases()
+ if err != nil {
+ t.Fatalf("ListAliases: %v", err)
+ }
+ if got[0].DNSStatus != "elsewhere" || got[0].DNSCheckedAt != 111 || got[0].CertExpiresAt != 222 {
+ t.Fatalf("UpsertAlias disturbed stamps that survived a plain re-upsert: %+v", got[0])
+ }
+}
+
+// TestFingerprintCoversAliases pins the fingerprint contract from the task
+// brief: an alias write moves the fingerprint (so the edge reloads and
+// starts routing it), but a STAMP write never does — stamping runs on a
+// 5-minute cadence and hashing it would force a pointless fleet-wide edge
+// reload every pass (same reasoning as the routes section's own omission of
+// dns_status/cert_expires_at).
+func TestFingerprintCoversAliases(t *testing.T) {
+ reg := openTestRegistry(t)
+ fp1, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.UpsertAlias(RouteAlias{Alias: "extra.example.com", Host: "app.example.com", Account: "bab", App: "myapp"}); err != nil {
+ t.Fatalf("UpsertAlias: %v", err)
+ }
+ fp2, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fp2 == fp1 {
+ t.Fatal("Fingerprint blind to an alias UpsertAlias")
+ }
+
+ if err := reg.SetAliasDNSStatus("extra.example.com", "pointing", 12345); err != nil {
+ t.Fatalf("SetAliasDNSStatus: %v", err)
+ }
+ fp3, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fp3 != fp2 {
+ t.Fatalf("Fingerprint moved on an alias stamp (%s -> %s) — the edge would reload for a change that does not touch routing", fp2, fp3)
+ }
+
+ if err := reg.DeleteAlias("extra.example.com"); err != nil {
+ t.Fatalf("DeleteAlias: %v", err)
+ }
+ fp4, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fp4 == fp2 {
+ t.Fatal("Fingerprint blind to a DeleteAlias")
+ }
+}
+
+// TestMigrateIdempotentWithAliases pins migrate()'s additive contract for the
+// new table: opening an already-migrated registry a second time (CREATE
+// TABLE IF NOT EXISTS route_aliases) must not error.
+func TestMigrateIdempotentWithAliases(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "registry.db")
+ r1, err := Open(path)
+ if err != nil {
+ t.Fatalf("first Open: %v", err)
+ }
+ if err := r1.UpsertAlias(RouteAlias{Alias: "extra.example.com", Host: "app.example.com"}); err != nil {
+ t.Fatalf("UpsertAlias: %v", err)
+ }
+ if err := r1.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ r2, err := Open(path)
+ if err != nil {
+ t.Fatalf("second Open over the same path: %v", err)
+ }
+ defer r2.Close()
+ got, err := r2.ListAliases()
+ if err != nil {
+ t.Fatalf("ListAliases: %v", err)
+ }
+ if len(got) != 1 || got[0].Alias != "extra.example.com" {
+ t.Fatalf("route_aliases row lost across a second Open: %+v", got)
+ }
+}
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go
index 39b18fc08..f5a4ad64b 100644
--- a/internal/status/snapshot.go
+++ b/internal/status/snapshot.go
@@ -93,6 +93,15 @@ type Probes struct {
// unknown/not-applicable for both, never a guess.
Release string
Tunnel string
+
+ // Aliases is this box's route_aliases rows (registry.RouteAlias), the
+ // agent's reg.ListAliases() read at statusTick — carried through raw so
+ // CollectBox can fold each row's DNS/cert stamp into Box.Metrics.Aliases
+ // alongside RouteVersions above. A failed read is the caller's problem
+ // (log-and-continue, see agent.go's statusTick), never CollectBox's: nil
+ // here means "no aliases known this tick," identical on the wire to "no
+ // alias rows exist."
+ Aliases []registry.RouteAlias
}
// BoxMetrics is the raw, PRIVATE block: real numbers, never banded. It lives
@@ -118,6 +127,33 @@ type BoxMetrics struct {
// no versioned route, same "absent, not empty" rule as the rest of this
// block.
RouteVersions map[string]string `json:"route_versions,omitempty"`
+ // Aliases is issue #151's per-hostname DNS/cert status, keyed by the
+ // alias hostname — the box's own route_aliases rows only (CollectBox is
+ // called per-box, same scope as RouteVersions above), never a fleet-wide
+ // join. It lives here for the identical reason RouteVersions does:
+ // status/boxes/<label>.json is bucket-private (this package's own doc
+ // comment, and dryrun.go's leak-scan self-check, which guards the PUBLIC
+ // feed only — fleet.json, index.html, status.css — and deliberately
+ // EXPECTS raw values inside status/boxes/*.json). BuildFleet copies Box's
+ // fields into FleetBox one at a time by name and never names Metrics, so
+ // these hostnames can never reach the public aggregation. Task 9's
+ // console list door is the intended reader. Omitted entirely (not a
+ // present-but-empty map) when this box has no alias rows, same
+ // "absent, not empty" rule as RouteVersions.
+ Aliases map[string]AliasReading `json:"aliases,omitempty"`
+}
+
+// AliasReading is one alias hostname's status as CollectBox saw it this
+// tick: Route is the route it points at (registry.RouteAlias.Host), DNSStatus
+// and CertExpiresAt are the box-stamped health columns (Task 2/5), carried
+// through unbanded like the rest of BoxMetrics. Route has no omitempty — an
+// alias with no route would be a data bug worth seeing, not a field worth
+// hiding; DNSStatus/CertExpiresAt omit when unstamped ("" / 0), same
+// unknown-stays-absent convention as every other Metrics field.
+type AliasReading struct {
+ Route string `json:"route"`
+ DNSStatus string `json:"dns_status,omitempty"`
+ CertExpiresAt int64 `json:"cert_expires_at,omitempty"`
}
// RouteCounts is aggregate-only: no host, no socket path, just how many.
@@ -340,6 +376,21 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box {
counts.Resident++
}
}
+ // aliasReadings is issue #151's Metrics.Aliases source, built once here
+ // (not a second pass) from p.Aliases — nil (not an empty map) until the
+ // first entry, same "nothing to report" convention as routeVersions
+ // above, and for the identical reason: an all-alias-less box's Metrics
+ // attachment check below must see exactly "nothing to report".
+ var aliasReadings map[string]AliasReading
+ for _, a := range p.Aliases {
+ if aliasReadings == nil {
+ aliasReadings = map[string]AliasReading{}
+ }
+ aliasReadings[a.Alias] = AliasReading{
+ Route: a.Host, DNSStatus: a.DNSStatus, CertExpiresAt: a.CertExpiresAt,
+ }
+ }
+
if awakeKnown && p.Listening != nil {
counts.Awake, counts.Asleep = &awake, &asleep
}
@@ -382,18 +433,20 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box {
}
// Metrics attaches only when something is known — an all-unknown Probes
- // AND no versioned route publishes no block at all, the same "unknown
- // stays absent, never a guess" rule every other field on Box follows,
- // and it keeps a box that can't answer any of the seven fields
- // indistinguishable on the wire from a box running code that predates
- // Metrics entirely.
+ // AND no versioned route AND no alias reading publishes no block at all,
+ // the same "unknown stays absent, never a guess" rule every other field
+ // on Box follows, and it keeps a box that can't answer any of the eight
+ // fields indistinguishable on the wire from a box running code that
+ // predates Metrics entirely.
if p.MemTotalBytes != nil || p.MemAvailBytes != nil || p.Load1 != nil ||
- p.DiskFreeBytes != nil || p.Release != "" || p.Tunnel != "" || len(routeVersions) > 0 {
+ p.DiskFreeBytes != nil || p.Release != "" || p.Tunnel != "" ||
+ len(routeVersions) > 0 || len(aliasReadings) > 0 {
box.Metrics = &BoxMetrics{
MemTotalBytes: p.MemTotalBytes, MemAvailBytes: p.MemAvailBytes,
Load1: p.Load1, DiskFreeBytes: p.DiskFreeBytes,
Release: p.Release, Tunnel: p.Tunnel,
RouteVersions: routeVersions,
+ Aliases: aliasReadings,
}
}
diff --git a/internal/status/status_test.go b/internal/status/status_test.go
index 3d0a93e94..1a2daa907 100644
--- a/internal/status/status_test.go
+++ b/internal/status/status_test.go
@@ -340,6 +340,44 @@ func TestCollectBoxRouteVersionsEmptyOmitsField(t *testing.T) {
}
}
+// ---- Task 6: alias readings (route_aliases DNS/cert stamps, private) -----
+
+// TestCollectBoxAliases proves CollectBox maps Probes.Aliases (registry rows
+// carried in by the agent's own reg.ListAliases() call, see agent.go's
+// statusTick) onto Box.Metrics.Aliases, keyed by the alias hostname — the
+// same private home as RouteVersions (status/boxes/<label>.json only, never
+// BuildFleet's public fleet.json) — and that an empty Probes.Aliases omits
+// the "aliases" key entirely rather than publishing a present-but-empty map.
+func TestCollectBoxAliases(t *testing.T) {
+ p := status.Probes{
+ MachineID: "m",
+ Aliases: []registry.RouteAlias{
+ {Alias: "extra.test", Host: "h.test", DNSStatus: "ok", CertExpiresAt: 1234567890},
+ },
+ }
+ b := status.CollectBox(nil, p, time.Now())
+ if b.Metrics == nil {
+ t.Fatal("metrics block missing — Aliases should have attached it")
+ }
+ got, ok := b.Metrics.Aliases["extra.test"]
+ if !ok || got.Route != "h.test" || got.DNSStatus != "ok" || got.CertExpiresAt != 1234567890 {
+ t.Fatalf("aliases[extra.test] = %+v (ok=%v), want {Route:h.test DNSStatus:ok CertExpiresAt:1234567890}", got, ok)
+ }
+
+ // Empty Probes.Aliases, but something else known so Metrics still
+ // attaches — the omission must be specific to the aliases key, not a
+ // side effect of the whole Metrics block being absent.
+ mt := int64(1)
+ b2 := status.CollectBox(nil, status.Probes{MachineID: "m", MemTotalBytes: &mt}, time.Now())
+ j, err := json.Marshal(b2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(j), "aliases") {
+ t.Fatalf("empty Probes.Aliases must omit the aliases key entirely, got: %s", j)
+ }
+}
+
// ---- Task 3: fleet merge + publisher --------------------------------------
// teststore is a fresh DirStore over a scratch directory — every fleet test
diff --git a/main.go b/main.go
index 57a13f569..75fea7ef0 100644
--- a/main.go
+++ b/main.go
@@ -53,6 +53,8 @@ func main() {
runRoute(args)
case "instances":
runInstances(args)
+ case "domains":
+ runDomains(args)
case "ship":
runShip(args)
case "promote":
@@ -153,6 +155,8 @@ Your apps — ship and run software on a CARLOS deployment
env plain per-app config vars: set | unset | list
secrets sealed per-app secrets: set | unset | list | genkey
instances an app's instances: enable | create | list | delete
+ domains a customer's own hostname attached to an app's route:
+ attach | detach | list
logs an app's merged log timeline: its own output plus platform
events (wakes, restarts, failures); -f follows live
ledger open ledgers: append | head | list | publish | blob | verify