carlos / platform Public

feat: console domains door — attach/detach/list hostnames (#151)

The /domains family on the CLI API, sharing the instance family's gate
verbatim (instanceGate): a member's token or an APP token bound to that
account+app, token-hash rate limiting, and the Releases-nil 503, in the
order the family fixed. titogo's router attaches its customers' hostnames
with the CARLOS_INSTANCE_TOKEN it already holds.

  POST   /api/cli/apps/{account}/{app}/domains        -> {host,route,created}
  DELETE /api/cli/apps/{account}/{app}/domains/{host} -> {host,removed}
  GET    /api/cli/apps/{account}/{app}/domains        -> {domains:[...]}

The neutralities the tests pin: a hostname another account holds is a 409
whose body is exactly {"error":"hostname unavailable"} and nothing else;
detach answers the same 200 shape for a hostname nobody claimed and for
one another account holds, touching neither the claim nor the serial; an
app token bound to a neighbouring app gets the byte-identical 404.

Attach validates in one order — normalize (traversal defence), deny list,
route — and the deny list is the operator's CONSOLE_DENY_DOMAINS plus the
two names the console derives itself: its own origin host and the platform
apps domain (spec §5), so a deployment cannot forget them. An omitted
route defaults only when the app has exactly one instance record.

List joins each claim to the box readings sweep through the same
non-stale rule stagedVersions uses: a hostname a live box reports reads
"live" with its dns_status/cert_expires_at, everything else "pending".

Serial bumps strictly after a successful write and its audit; a no-op
detach bumps nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 0d7ae2e64305b73deb82c5627f7ae19081fea889 parent 086f150
5 files changed, +774 −2
  • cmd/console/main.go +20 −0
  • internal/console/apidomains.go +309 −0
  • internal/console/apidomains_test.go +427 −0
  • internal/console/authcli.go +9 −2
  • internal/console/server.go +9 −0
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/internal/console/apidomains.go b/internal/console/apidomains.go
new file mode 100644
index 000000000..1174720b2
--- /dev/null
+++ b/internal/console/apidomains.go
@@ -0,0 +1,309 @@
+// 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 two names this
+// console can derive without being told (spec §5: "all derived from
+// console/deployment config, not hardcoded").
+//
+// The derived two 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 an alias claim there would be one tenant claiming another
+// tenant's address — the same sibling-apex hazard handleAPIInstanceCreate
+// refuses within an account, here refused 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.
+//
+// Parsed per call rather than cached at boot: it is one url.Parse of a short
+// string on a door that then does bucket I/O, and it stays honest for an
+// embedder (or a test) that sets Origin after NewServer.
+func (s *Server) deniedDomains() []string {
+ deny := make([]string, 0, len(s.cfg.DenyDomains)+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)
+ }
+ 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, 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
+ }
+ 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..b67acfe0f
--- /dev/null
+++ b/internal/console/apidomains_test.go
@@ -0,0 +1,427 @@
+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, and the
+// console's own origin host.
+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"
+ 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
+ } {
+ 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)
+ }
+}
+
+// TestAPIDomainSerialBump: boxes converge off control/serial, so every
+// EFFECTIVE write announces itself — and a no-op detach, which changed
+// nothing a box reads, does not.
+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 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 != 2 {
+ t.Fatalf("serial after an effective detach = %d, want 2", 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 != 2 {
+ t.Fatalf("serial after a no-op detach = %d, want it unmoved at 2", 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