package console // Proof of control at the /domains doors (#215, opt-in: CONSOLE_DOMAIN_PROOF), and the apps-domain // namespace rules that came with it. Before this, a custom hostname went to // whichever account attached it first: one whose DNS already pointed at the // edge was served for the claimant with a platform-issued certificate, and // any name could be held forever by someone who never controlled it. And // without CONSOLE_RESERVE_PLATFORM_DOMAINS, one account could attach names // inside another's . namespace. import ( "context" "encoding/json" "net" "net/http" "strings" "testing" "time" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // noTXT is a zone with no proof in it. func noTXT(calls *int) func(context.Context, string) ([]string, error) { return func(_ context.Context, name string) ([]string, error) { *calls++ return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true} } } // txtFor is a zone that carries exactly one account's proof for one host, // beside unrelated TXT noise. func txtFor(s *Server, account, host string) func(context.Context, string) ([]string, error) { return func(_ context.Context, name string) ([]string, error) { if name != domains.ChallengeName(host) { return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true} } return []string{"v=spf1 -all", domains.ChallengeToken(s.challengeDeployment(), account, host)}, nil } } type proofAttachResp struct { Host string `json:"host"` Route string `json:"route"` Created bool `json:"created"` Verified *bool `json:"verified"` Verify dnsRecord `json:"verify"` } type proofListResp struct { Domains []struct { Host string `json:"host"` State string `json:"state"` Verified bool `json:"verified"` Guidance string `json:"guidance"` Records []dnsRecord `json:"records"` } `json:"domains"` } // The audit's takeover, at the CLI door: a member attaches a hostname whose // DNS they do not control. On main this was a live claim — served the moment // a box reconciled it, certificate and all. Now it is stored pending, the // answer says exactly which record proves it, nothing is announced to the // fleet, and repeating the attach once the record exists verifies it. func TestAPIDomainAttachWithoutProofIsPending(t *testing.T) { fx, member := instanceFixture(t, "code-dom-proof") fx.srv.cfg.DomainProof = true lookups := 0 fx.srv.lookupTXT = noTXT(&lookups) const host = "victim.example.com" before := serialValue(t, fx) code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(host, "go.tito.io")) if code != http.StatusOK { t.Fatalf("attach = %d %s, want 200", code, out) } var got proofAttachResp if err := json.Unmarshal([]byte(out), &got); err != nil { t.Fatal(err) } wantToken := domains.ChallengeToken(fx.srv.challengeDeployment(), "bab", host) if got.Verified == nil || *got.Verified || got.Verify.Type != "TXT" || got.Verify.Name != "_carlos-challenge."+host || got.Verify.Value != wantToken { t.Fatalf("attach answer = %s, want verified false and the TXT record %s = %s", out, "_carlos-challenge."+host, wantToken) } if lookups != 1 { t.Errorf("TXT lookups = %d, want 1", lookups) } if c := claimOf(t, fx, host); !c.Pending { t.Fatalf("claim = %+v, want pending", *c) } if after := serialValue(t, fx); after != before { t.Errorf("serial moved %q -> %q for a pending claim no box acts on", before, after) } // The list says the same thing, in both forms. code, _, out = rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "") if code != http.StatusOK { t.Fatalf("list = %d %s", code, out) } var list proofListResp if err := json.Unmarshal([]byte(out), &list); err != nil { t.Fatal(err) } if len(list.Domains) != 1 || list.Domains[0].State != StateUnverified || list.Domains[0].Verified { t.Fatalf("list = %s, want one unverified row", out) } row := list.Domains[0] found := false for _, r := range row.Records { if r.Type == "TXT" && r.Name == "_carlos-challenge."+host && r.Value == wantToken && r.Required { found = true } } if !found || !strings.Contains(row.Guidance, wantToken) { t.Fatalf("list row = %+v, want the TXT record in records (required) and in guidance", row) } // Publish the record and attach again: verified, announced. fx.srv.lookupTXT = txtFor(fx.srv, "bab", host) code, _, out = rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(host, "go.tito.io")) if code != http.StatusOK { t.Fatalf("re-attach = %d %s", code, out) } got = proofAttachResp{} _ = json.Unmarshal([]byte(out), &got) if got.Verified == nil || !*got.Verified || got.Verify.Name != "" { t.Fatalf("re-attach answer = %s, want verified true and no verify record", out) } if c := claimOf(t, fx, host); c.Pending || c.PendingUntil != "" { t.Fatalf("claim = %+v, want verified", *c) } if after := serialValue(t, fx); after == before { t.Error("verifying the claim did not bump the serial") } } // Another account's proof is not this account's proof: the token is // account-bound, so a record published for one account cannot be replayed. func TestAPIDomainAttachRefusesAnotherAccountsProof(t *testing.T) { fx, member := instanceFixture(t, "code-dom-replay") fx.srv.cfg.DomainProof = true const host = "shop.example.com" fx.srv.lookupTXT = txtFor(fx.srv, "cdef", host) code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(host, "go.tito.io")) if code != http.StatusOK { t.Fatalf("attach = %d %s", code, out) } if c := claimOf(t, fx, host); !c.Pending { t.Fatalf("claim = %+v, want pending: the TXT record proves account cdef, not bab", *c) } } // Squatting: an unproven claim no longer holds a name against its owner. A // proven attach supersedes it, an unproven one meets the ordinary 409 — the // same bytes a verified holder produces, so a refusal still never says how // firmly a name is held. func TestAPIDomainProvenOwnerSupersedesSquatter(t *testing.T) { fx, member := instanceFixture(t, "code-dom-squat") fx.srv.cfg.DomainProof = true ctx := context.Background() const squatted, held = "shop.example.com", "held.example.com" if _, _, _, err := domains.AttachClaimProven(ctx, fx.rel, domains.Claim{ Host: squatted, Account: "cdef", App: "squat", Route: "x.cdef.oncarlos.com", }, time.Now(), func() bool { return false }); err != nil { t.Fatal(err) } if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{ Host: held, Account: "cdef", App: "squat", Route: "x.cdef.oncarlos.com", }); err != nil { t.Fatal(err) } lookups := 0 fx.srv.lookupTXT = noTXT(&lookups) code1, _, body1 := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(squatted, "go.tito.io")) code2, _, body2 := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(held, "go.tito.io")) if code1 != http.StatusConflict || code1 != code2 || body1 != body2 { t.Fatalf("unproven attach over a reservation = %d %q, over a verified claim = %d %q; want the same 409", code1, body1, code2, body2) } fx.srv.lookupTXT = txtFor(fx.srv, "bab", squatted) code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(squatted, "go.tito.io")) if code != http.StatusOK { t.Fatalf("proven attach over a reservation = %d %s, want 200", code, out) } if c := claimOf(t, fx, squatted); c.Account != "bab" || c.App != "titogo" || c.Pending { t.Fatalf("claim = %+v, want bab/titogo verified", *c) } // A verified claim is still first-writer-wins against proof: proving // control of a name someone else already proved is a support case, not // something the door decides. fx.srv.lookupTXT = txtFor(fx.srv, "bab", held) if code, _, _ := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(held, "go.tito.io")); code != http.StatusConflict { t.Fatalf("proven attach over a verified claim = %d, want 409", code) } } // The first-route branch writes NO record for an unproven claim, because the // record is what turns the host into a real route; the attach that proves it // declares the record then. func TestAttachFirstRouteWaitsForProof(t *testing.T) { fx, acct := attachFixture(t) fx.srv.cfg.DomainProof = true shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") const host = "story.example.com" lookups := 0 fx.srv.lookupTXT = noTXT(&lookups) ops := &opStore{Store: fx.rel} fx.srv.cfg.Releases = ops res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref != nil { t.Fatalf("attach refused %+v", ref) } if !res.Pending || res.FirstRoute || res.Verify.Name != domains.ChallengeName(host) { t.Fatalf("result = %+v, want pending, no first route, and the proof record", res) } keys, err := fx.rel.List(context.Background(), provision.RecordsPrefix(fx.sqid, "story")) if err != nil { t.Fatal(err) } if len(keys) != 0 { t.Fatalf("records for an unproven first route = %v, want none", keys) } if ops.opIndex("put "+release.SerialKey) >= 0 { t.Errorf("ops %v: serial bumped for a pending claim", ops.ops) } fx.srv.lookupTXT = txtFor(fx.srv, fx.sqid, host) res, ref = fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref != nil { t.Fatalf("re-attach refused %+v", ref) } if res.Pending || !res.FirstRoute { t.Fatalf("result = %+v, want verified first route", res) } if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); err != nil { t.Fatalf("record after proof: %v", err) } } // Item 2 of #215: another account's . namespace is refused // whatever CONSOLE_RESERVE_PLATFORM_DOMAINS says, a first route may not be // minted under a namespace no account holds yet (that would order a // wildcard certificate for the invented label), and the account's own // namespace needs no TXT proof — there is no zone the member could publish // one in. func TestAppsDomainNamespacesBelongToTheirAccounts(t *testing.T) { fx, acct := attachFixture(t) fx.srv.cfg.DomainProof = true if fx.srv.cfg.ReservePlatformDomains { t.Fatal("fixture reserves platform domains; this test is about deployments that do not") } if err := fx.st.CreateAccount(42, "cdef", "Other", "sam@keymail.dev", time.Now()); err != nil { t.Fatal(err) } shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") lookups := 0 fx.srv.lookupTXT = noTXT(&lookups) for _, host := range []string{"new.cdef.oncarlos.com", "cdef.oncarlos.com", "a.b.cdef.oncarlos.com"} { _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref == nil || ref.Status != http.StatusUnprocessableEntity || ref.Msg != "that hostname is platform-managed" { t.Errorf("attach %s in another account's namespace = %+v, want the platform-managed refusal", host, ref) } if _, err := domains.GetClaim(context.Background(), fx.rel, host); err == nil { t.Errorf("claim written for %s", host) } } // The CLI door asks the same question. if !fx.srv.domainHostDenied("new.cdef.oncarlos.com", fx.sqid) { t.Error("domainHostDenied admits another account's namespace") } // An unheld sqid-shaped namespace: no first route. if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "x.zzz.oncarlos.com", "", "", "paul@keymail.dev"); ref == nil { t.Error("first route under an unheld namespace was accepted") } // The account's own namespace: attached and verified without a lookup. own := "blog." + fx.sqid + ".oncarlos.com" res, ref := fx.srv.attachDomain(context.Background(), acct, "story", own, "", "", "paul@keymail.dev") if ref != nil { t.Fatalf("attach in own namespace refused %+v", ref) } if res.Pending || !res.FirstRoute { t.Errorf("own-namespace attach = %+v, want verified first route", res) } if lookups != 0 { t.Errorf("TXT lookups = %d, want none: own namespace needs no proof", lookups) } // A name outside every namespace is unchanged on a self-hosted // deployment: attachable (pending proof like any custom domain). fx.srv.cfg.Registry = attachRegistry(t, registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", Account: fx.sqid, Channel: "canary/live", Status: "ready"}) if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "www.oncarlos.com", "", "", "paul@keymail.dev"); ref != nil { t.Errorf("alias attach of www.oncarlos.com refused %+v; self-hosted behaviour must not change", ref) } } // serialValue reads control/serial, "" when absent. func serialValue(t *testing.T, fx *consoleFixture) string { t.Helper() b, _, err := fx.rel.Get(context.Background(), release.SerialKey) if err != nil { return "" } return string(b) } // With CONSOLE_DOMAIN_PROOF unset — the default — nothing about attach // changes (#215 keeps the 2026-08-26/27 ruling until a deployment opts in): // no TXT lookup is ever made, every claim is written verified and announced, // a first route declares its record at once, and the answer says verified. func TestDomainProofOffBehavesAsBefore(t *testing.T) { fx, member := instanceFixture(t, "code-dom-proof-off") if fx.srv.cfg.DomainProof { t.Fatal("DomainProof is on by default") } lookups := 0 fx.srv.lookupTXT = noTXT(&lookups) before := serialValue(t, fx) code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody("tickets.example.com", "go.tito.io")) if code != http.StatusOK { t.Fatalf("attach = %d %s", code, out) } var got proofAttachResp _ = json.Unmarshal([]byte(out), &got) if got.Verified == nil || !*got.Verified || got.Verify.Name != "" { t.Fatalf("attach answer = %s, want verified true and no verify record", out) } if c := claimOf(t, fx, "tickets.example.com"); c.Pending || c.PendingUntil != "" { t.Fatalf("claim = %+v, want verified", *c) } if serialValue(t, fx) == before { t.Error("attach did not bump the serial") } afx, acct := attachFixture(t) afx.srv.lookupTXT = noTXT(&lookups) shipVersion(t, afx, "story", "v1") seedChannel(t, afx, "story", "canary/live", "v1") res, ref := afx.srv.attachDomain(context.Background(), acct, "story", "story.example.com", "", "", "paul@keymail.dev") if ref != nil || res.Pending || !res.FirstRoute { t.Fatalf("first-route attach = %+v / %+v, want a verified first route", res, ref) } if _, err := provision.GetRecord(context.Background(), afx.rel, afx.sqid, "story", "story.example.com"); err != nil { t.Fatalf("record: %v", err) } if lookups != 0 { t.Errorf("TXT lookups = %d with proof off, want none", lookups) } } // A plain re-attach of one's own PENDING first-route claim (no route, no // kind — what `carlos domains attach host` sends) must look for the proof. // The own-first-route short-circuit used to answer it {created:false} with // no verified field, which the CLI reads as attached (#215 review). func TestAPIDomainPlainReattachVerifiesPendingFirstRoute(t *testing.T) { fx, acct := attachFixture(t) fx.srv.cfg.DomainProof = true shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") if _, err := fx.st.CreateAppIn(OpsAccountID, "story", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-dom-plain-reattach") const host = "story.example.com" lookups := 0 fx.srv.lookupTXT = noTXT(&lookups) if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "static", "", "paul@keymail.dev"); ref != nil { t.Fatalf("seed attach refused %+v", ref) } if c := claimOf(t, fx, host); !c.Pending || c.Route != "" { t.Fatalf("seed claim = %+v, want a pending first-route claim", *c) } post := func() proofAttachResp { t.Helper() code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, fx.sqid, "story"), token, attachBody(host, "")) if code != http.StatusOK { t.Fatalf("plain re-attach = %d %s", code, out) } var got proofAttachResp if err := json.Unmarshal([]byte(out), &got); err != nil { t.Fatal(err) } return got } lookups = 0 if got := post(); got.Verified == nil || *got.Verified || lookups != 1 { t.Fatalf("plain re-attach without proof = %+v after %d lookups, want verified false after one", got, lookups) } fx.srv.lookupTXT = txtFor(fx.srv, fx.sqid, host) if got := post(); got.Verified == nil || !*got.Verified || got.Route != host { t.Fatalf("plain re-attach with proof = %+v, want verified first route", got) } if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); err != nil { t.Fatalf("record after proof: %v", err) } } // A deleted account's namespace stays reserved (#215 review): its tombstone // keeps the sqid from being reissued, and a name under it would still serve // through the apps-domain wildcard looking like that account's address. func TestDeletedAccountsNamespaceStaysReserved(t *testing.T) { fx, _ := attachFixture(t) if err := fx.st.CreateAccount(42, "cdef", "Gone", "sam@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if err := fx.st.DeleteAccount(42, time.Now()); err != nil { t.Fatal(err) } if a, _ := fx.st.AccountBySqid("cdef"); a != nil { t.Fatal("account not deleted") } if !fx.srv.domainHostDenied("new.cdef.oncarlos.com", fx.sqid) { t.Error("a deleted account's namespace is attachable") } }