carlos / platform Public

console: cover the paths door's refusals, and read back the published map

Review round 1. Three gaps, none of them in behaviour that shipped wrong:

The ErrBadPathTarget 422 had no test on either side, so deleting the %w wrap
in claims.go restored the 502 "try again" with a green suite. Covered at the
request level and through the sentinel in the domains unit test; both fail
without the wrap.

The control-plane guard on the paths door had no test, against a file with one
per call site. Added, including the constant-across-host-shapes property the
guard's placement before normalisation exists to give.

Spec section 8's other read surface was missing, and nothing exposed a claim's
Paths at all: `carlos routes` lists registry-derived rows, and the reconcile
pass drops any entry whose target is not a same-account local route — so a
refused flip was visible nowhere. The per-host claim door now answers the map
as published, and `carlos domains paths <host>` reads it. Read-only, and its
footer names which kind of state it is showing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 6b93daa3f041b79adc0066489c0ecf63d503fdfd parent 7a9df87
7 files changed, +423 −11
  • apimode.go +28 −0
  • domains_cli.go +69 −1
  • domains_cli_test.go +95 −0
  • internal/console/apidomainpaths_test.go +92 −0
  • internal/console/apidomains.go +28 −0
  • internal/console/controlplane_test.go +83 −0
  • internal/domains/paths_test.go +28 −10
diff --git a/apimode.go b/apimode.go
index 54af7d3c4..ccfbebd2e 100644
--- a/apimode.go
+++ b/apimode.go
@@ -2684,6 +2684,34 @@ func (s *apiSession) domainList(ctx context.Context, a apiAcct, app string) ([]a
return res.Domains, nil
}
+// domainPaths reads ONE hostname's published prefix map off the per-host
+// claim door. Published, not derived: this is the map the owning platform
+// PUT, which is a different fact from the route_paths rows `carlos routes`
+// prints. The reconcile pass drops an entry whose target is not a
+// same-account route on the box, so a prefix present here and absent there is
+// a refused flip — the one thing an operator debugging a migration needs to
+// see, and invisible everywhere else.
+//
+// A console that predates path routing sends no `paths` key, which decodes as
+// an empty map: "this hostname splits no traffic" is the same answer such a
+// deployment would give if asked, so there is nothing to distinguish.
+func (s *apiSession) domainPaths(ctx context.Context, a apiAcct, app, host string) (map[string]string, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/domains/"+url.PathEscape(host)))
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Paths map[string]string `json:"paths"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("domains paths: unreadable response: %w", err)
+ }
+ return res.Paths, 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/domains_cli.go b/domains_cli.go
index 8f602970c..04b130ea1 100644
--- a/domains_cli.go
+++ b/domains_cli.go
@@ -12,11 +12,12 @@ import (
"flag"
"fmt"
"log"
+ "sort"
"strings"
"time"
)
-const domainsUsage = `usage: carlos domains <attach|detach|list> [flags]
+const domainsUsage = `usage: carlos domains <attach|detach|list|paths> [flags]
attach --app <a> [--route <r>] [--wildcard] [--catchall] [flags] <hostname>
claim a customer's hostname for the app, pointed at
@@ -29,6 +30,11 @@ const domainsUsage = `usage: carlos domains <attach|detach|list> [flags]
list --app <a> [flags]
this app's hostname claims, joined to the fleet's own
readings sweep (DNS/CERT-EXPIRES/WILDCARD/CATCHALL/DELEGATION)
+ paths --app <a> [flags] <hostname>
+ the leading path segments this hostname's traffic is split
+ by, as PUBLISHED. Read-only: the platform that owns the
+ hostname republishes the whole map, so there is no verb
+ here that writes one.
--account <a> account (sqid or name; default "ops")
--account-id <id> account sqid, matched as a sqid ONLY
@@ -49,6 +55,9 @@ const domainsDetachUsage = `usage: carlos domains detach --app <a> [--account <a
const domainsListUsage = `usage: carlos domains list --app <a> [--account <a>] [--account-id <id>] [--console <origin>]
`
+const domainsPathsUsage = `usage: carlos domains paths --app <a> [--account <a>] [--account-id <id>] [--console <origin>] <hostname>
+`
+
// 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.
@@ -64,6 +73,9 @@ func runDomains(args []string) {
case "list":
runDomainsList(args[1:])
return
+ case "paths":
+ runDomainsPaths(args[1:])
+ return
}
}
log.Fatal(domainsUsage)
@@ -260,6 +272,62 @@ func runDomainsList(args []string) {
w.Flush()
}
+// runDomainsPaths prints one hostname's PUBLISHED prefix map — the leading
+// path segments its traffic is split by, and the route each is served by.
+//
+// This is desired state, and the distinction from `carlos routes` is the whole
+// reason the verb exists. `carlos routes` prints the route_paths rows a box
+// derived, and the reconcile pass silently drops any entry whose target is not
+// a route of the same account on that box. So a prefix that appears HERE and
+// not there is a flip the platform published and the deployment refused —
+// which is the state an operator debugs, and which no other surface shows. The
+// footer says so rather than leaving the reader to know it.
+//
+// READ-ONLY, and there is deliberately no sibling that writes: the platform
+// that owns the hostname regenerates this map from its own database and
+// republishes it wholesale, so a CLI writer would be a second writer of state
+// whose source of truth is elsewhere, and the two would drift the first time
+// anyone used it.
+func runDomainsPaths(args []string) {
+ fs := flag.NewFlagSet("domains paths", flag.ExitOnError)
+ app := appFlag(fs, "app the hostname belongs to")
+ consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
+ fs.Usage = flagUsage(fs, domainsPathsUsage)
+ fs.Parse(args)
+ host := strings.TrimSpace(fs.Arg(0))
+ if host == "" || fs.NArg() > 1 {
+ log.Fatal(domainsPathsUsage)
+ }
+ sess := requireAPISession("paths", *consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, domainsPathsUsage)
+ paths, err := sess.domainPaths(context.Background(), acct, app2, host)
+ if err != nil {
+ log.Fatalf("domains paths: %v", err)
+ }
+ if len(paths) == 0 {
+ mutedf(stdout, "%s splits no traffic by path", host)
+ return
+ }
+ // Sorted by prefix: a map has no order, and a listing that reshuffles
+ // between calls is one nobody can diff.
+ prefixes := make([]string, 0, len(paths))
+ for p := range paths {
+ prefixes = append(prefixes, p)
+ }
+ sort.Strings(prefixes)
+ w := newTable(stdout)
+ fmt.Fprintln(w, "PREFIX\tTARGET")
+ for _, p := range prefixes {
+ fmt.Fprintf(w, "/%s\t%s\n", p, paths[p])
+ }
+ w.Flush()
+ mutedf(stdout, "(published desired state; \"carlos routes --app %s\" shows which of these a box is actually serving)", app2)
+}
+
// printDomainRecords prints the records the member must create, grouped by
// alternative. Nothing is printed when the door sent none — a pointing
// hostname has nothing left to do, and an older console sends no records at
diff --git a/domains_cli_test.go b/domains_cli_test.go
index abed78e91..92872fe70 100644
--- a/domains_cli_test.go
+++ b/domains_cli_test.go
@@ -271,6 +271,12 @@ type fakeDomainsConsole struct {
attachCreated bool
lastDetachPath string
detachRemoved bool
+ // paths is what the per-host GET answers as the hostname's PUBLISHED
+ // prefix map, and lastPathsPath records the URL it was asked for. nil
+ // means the console sends no `paths` key at all — the shape a deployment
+ // predating path routing sends.
+ paths map[string]string
+ lastPathsPath string
}
func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole {
@@ -299,6 +305,18 @@ func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole {
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 && strings.Contains(r.URL.Path, "/domains/"):
+ // The per-host claim door, which carries the published prefix
+ // map. Matched BEFORE the catch-all list GET below, which would
+ // otherwise answer it with a list body and no `paths` key at all
+ // — making an assertion about the map impossible to distinguish
+ // from a client that never read it.
+ fc.lastPathsPath = r.URL.Path
+ body := map[string]any{"domain": map[string]any{"host": "tickets.example.com"}}
+ if fc.paths != nil {
+ body["paths"] = fc.paths
+ }
+ json.NewEncoder(w).Encode(body)
case r.Method == http.MethodGet:
json.NewEncoder(w).Encode(map[string]any{
"domains": []map[string]any{
@@ -586,6 +604,81 @@ func TestRunDomainsAttachWildcardFlagSentOnlyWhenNamed(t *testing.T) {
}
}
+// TestRunDomainsPathsPrintsThePublishedMap: the read surface for path
+// routing. It prints the map as PUBLISHED, sorted by prefix, and says in
+// words that this is desired state — because the other surface (`carlos
+// routes`) prints the rows a box DERIVED, and the difference between the two
+// is the whole diagnostic: the reconcile pass drops an entry whose target is
+// not a same-account route on that box, so a prefix visible here and not
+// there is a flip the deployment refused.
+func TestRunDomainsPathsPrintsThePublishedMap(t *testing.T) {
+ fc := domainsCLIFixture(t)
+ // Deliberately out of alphabetical order in the map, and with one target
+ // that is not a route of this deployment at all — the refused entry this
+ // surface exists to make visible.
+ fc.paths = map[string]string{
+ "paulca": "go.tito.io",
+ "bigcorp": "not-deployed-here.example",
+ "acme": "go.tito.io",
+ }
+ buf := captureStdout(t)
+
+ runDomains([]string{"paths", "-app", "tito", "ti.to"})
+
+ out := buf.String()
+ // It asked the per-host claim door for the hostname it was given.
+ if !strings.HasSuffix(fc.lastPathsPath, "/domains/ti.to") {
+ t.Fatalf("asked for %q, want the per-host claim door for ti.to", fc.lastPathsPath)
+ }
+ for _, want := range []string{
+ "PREFIX", "TARGET",
+ "/acme", "/bigcorp", "/paulca",
+ "go.tito.io", "not-deployed-here.example",
+ } {
+ if !strings.Contains(out, want) {
+ t.Fatalf("stdout missing %q:\n%s", want, out)
+ }
+ }
+ // Sorted by prefix: a map has no order, and a listing that reshuffles
+ // between calls is one nobody can diff.
+ acme, bigcorp, paulca := strings.Index(out, "/acme"), strings.Index(out, "/bigcorp"), strings.Index(out, "/paulca")
+ if !(acme < bigcorp && bigcorp < paulca) {
+ t.Fatalf("rows are not prefix-sorted (acme=%d bigcorp=%d paulca=%d):\n%s", acme, bigcorp, paulca, out)
+ }
+ // And it says which KIND of state this is. Without that line a reader
+ // takes a published map for what is serving, which is the exact confusion
+ // that makes a refused flip look like a working one.
+ if !strings.Contains(out, "published desired state") || !strings.Contains(out, "carlos routes") {
+ t.Fatalf("stdout does not distinguish published state from what a box serves:\n%s", out)
+ }
+}
+
+// TestRunDomainsPathsSaysSoWhenThereAreNone: a hostname with no prefix map
+// prints a line, not a bare header. An older console sends no `paths` key at
+// all and must read the same way — "this hostname splits no traffic by path"
+// is the true answer for a deployment that has no path routing.
+func TestRunDomainsPathsSaysSoWhenThereAreNone(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ paths map[string]string
+ }{
+ {"an empty published map", map[string]string{}},
+ {"a console that predates path routing", nil},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ fc := domainsCLIFixture(t)
+ fc.paths = tc.paths
+ buf := captureStdout(t)
+
+ runDomains([]string{"paths", "-app", "tito", "ti.to"})
+
+ if out := buf.String(); !strings.Contains(out, "splits no traffic by path") {
+ t.Fatalf("stdout = %q, want a line saying there are none", out)
+ }
+ })
+ }
+}
+
// ---- subprocess: the usage-error / bad-argument paths ---------------------
// TestDomainsCLIUsageErrors drives every domains verb with missing or
@@ -623,6 +716,8 @@ func TestDomainsCLIUsageErrors(t *testing.T) {
{"detach no app", []string{"domains", "detach", "tickets.example.com"}, "no deployment target configured"},
{"detach no positional host", []string{"domains", "detach", "-app", "tito"}, "usage: carlos domains detach"},
{"list no app", []string{"domains", "list"}, "no deployment target configured"},
+ {"paths no app", []string{"domains", "paths", "ti.to"}, "no deployment target configured"},
+ {"paths no positional host", []string{"domains", "paths", "-app", "tito"}, "usage: carlos domains paths"},
} {
t.Run(tc.name, func(t *testing.T) {
out, err := run(tc.args...)
diff --git a/internal/console/apidomainpaths_test.go b/internal/console/apidomainpaths_test.go
index ffb39de4a..014f747c0 100644
--- a/internal/console/apidomainpaths_test.go
+++ b/internal/console/apidomainpaths_test.go
@@ -17,6 +17,7 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "strings"
"testing"
"time"
@@ -222,6 +223,97 @@ func TestAPIDomainPathsRejectsBadPrefix(t *testing.T) {
}
}
+// TestAPIDomainPathsRejectsBadTarget is the OTHER half of the prefix map, and
+// the half that is easiest to get wrong in the handler rather than the store: a
+// malformed target hostname is the CALLER's error, so it must answer 422
+// naming the entry — never the 502 "try again" that a store failure answers
+// with, which would tell the publishing platform to retry a body that will
+// fail identically forever.
+//
+// Pinned here at the REQUEST level as well as in
+// TestSetClaimPathsRejectsUnnormalisedTarget, because the two can fail
+// independently: the store can classify the error correctly while the door
+// drops it into the wrong branch, and dropping the %w wrap in claims.go is
+// enough to move every case in this test from 422 to 502.
+func TestAPIDomainPathsRejectsBadTarget(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-paths-badtarget")
+ claimedHost(t, fx, member, "ti.to")
+ for _, target := range []string{"notahostname", "GO.tito.io", "go.tito.io.", "go tito io"} {
+ code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), member,
+ pathsBody(t, map[string]string{"paulca": target}))
+ if code != http.StatusUnprocessableEntity {
+ t.Fatalf("target %q = %d %s, want 422 — a malformed target is the caller's error, not ours", target, code, out)
+ }
+ // The offending ENTRY is named. With 10,000 prefixes in a published
+ // map, a refusal that does not say which one is unactionable.
+ if !strings.Contains(out, "paulca") || !strings.Contains(out, target) {
+ t.Fatalf("target %q refusal = %s, want it to name the prefix and the target", target, out)
+ }
+ if c := claimOf(t, fx, "ti.to"); len(c.Paths) != 0 {
+ t.Fatalf("target %q: claim paths = %v, want none — a refused map must not partially land", target, c.Paths)
+ }
+ }
+}
+
+// TestAPIDomainGetAnswersThePublishedMap is the READ half of the feature, and
+// the reason it cannot be left to `carlos routes`: that command lists the
+// route_paths rows a box DERIVED, and the reconcile pass silently drops any
+// entry whose target is not a route of the same account on that box. So an
+// entry the deployment REFUSED — a cross-tenant target, a target that is not
+// deployed here — appears on no registry-derived surface at all. This door
+// answers the map as PUBLISHED, which is the only place that refused entry is
+// visible, and the state an operator debugging a stalled migration is looking
+// for.
+//
+// Asserted with a target that is a plausible hostname but NOT a route of this
+// deployment, i.e. exactly the entry the pass would drop. It has to come back
+// regardless: this door reports what was published, and makes no judgement
+// about what a box did with it.
+func TestAPIDomainGetAnswersThePublishedMap(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-get-paths")
+ claimedHost(t, fx, member, "ti.to")
+
+ // Before anything is published: an object, never null, so a client can
+ // loop without telling two spellings of "none" apart.
+ if got := getPaths(t, fx, member, "ti.to"); got == nil || len(got) != 0 {
+ t.Fatalf("paths on a fresh claim = %v (nil=%t), want an empty object", got, got == nil)
+ }
+
+ publishPaths(t, fx, member, "ti.to", map[string]string{
+ "paulca": "go.tito.io",
+ "bigcorp": "not-deployed-here.example",
+ })
+ got := getPaths(t, fx, member, "ti.to")
+ if len(got) != 2 || got["paulca"] != "go.tito.io" {
+ t.Fatalf("paths = %v, want both published entries", got)
+ }
+ if got["bigcorp"] != "not-deployed-here.example" {
+ t.Fatalf("paths = %v, want the entry whose target is not a route here — a refused flip is exactly what this door exists to show", got)
+ }
+
+ // And it tracks a republish, so it can be used to check a rollback landed.
+ publishPaths(t, fx, member, "ti.to", map[string]string{})
+ if got := getPaths(t, fx, member, "ti.to"); len(got) != 0 {
+ t.Fatalf("paths after a clearing publish = %v, want none", got)
+ }
+}
+
+// getPaths reads one hostname's published map off the per-host claim door.
+func getPaths(t *testing.T, fx *consoleFixture, token, host string) map[string]string {
+ t.Helper()
+ code, _, out := rawReq(t, http.MethodGet, domainGetURL(fx, "bab", "titogo", host), token, "")
+ if code != http.StatusOK {
+ t.Fatalf("get %s = %d %s, want 200", host, code, out)
+ }
+ var got struct {
+ Paths map[string]string `json:"paths"`
+ }
+ if err := json.Unmarshal([]byte(out), &got); err != nil {
+ t.Fatalf("get body %s: %v", out, err)
+ }
+ return got.Paths
+}
+
// TestAPIDomainPathsIsPublishOnly: publishing a prefix map decides where an
// account's traffic goes, which is a deployment-shaped act. The instance
// credential an app's own router holds reaches the attach and detach doors
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 4551c4e28..35bcdd10d 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -661,6 +661,17 @@ func (s *Server) handleAPIDomainPaths(w http.ResponseWriter, r *http.Request) {
})
}
+// pathsOrEmpty renders a claim's prefix map for the wire as an object even
+// when it is absent. Claim.Paths is omitempty, so nil and empty are one
+// stored state; letting nil marshal to `null` would make a consumer tell two
+// spellings of the same answer apart before it could iterate.
+func pathsOrEmpty(paths map[string]string) map[string]string {
+ if paths == nil {
+ return map[string]string{}
+ }
+ return paths
+}
+
// 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
@@ -962,6 +973,23 @@ func (s *Server) handleAPIDomainGet(w http.ResponseWriter, r *http.Request) {
warmRecord(ctx, s.cfg.Releases, c.Host), s.dnsTargets(), now,
c.Wildcard && s.delegationZone != ""),
"deployment": s.domainCapability(ctx, acct.Sqid, app),
+ // The prefix map as PUBLISHED, and this is the only surface that
+ // answers for it. `carlos routes` shows the route_paths rows the
+ // reconcile pass derived, which is ACTUAL state on one box — the pass
+ // silently drops an entry whose target is not a same-account local
+ // route, so an entry it refused appears on no other surface at all.
+ // That is exactly the state an operator debugs, and a migration whose
+ // refusals are invisible fails silently.
+ //
+ // A sibling key rather than a field on domainItem, deliberately: the
+ // list door shares that struct, and a host at MaxPathsPerClaim would
+ // put 10,000 entries into every row of `carlos domains list`. The
+ // whole map belongs on the door that already narrows to one hostname.
+ //
+ // Always an object, never null: an absent map and an empty one are one
+ // state (Claim.Paths is omitempty), and a client should not have to
+ // tell a nil from a {} to loop over it.
+ "paths": pathsOrEmpty(c.Paths),
})
}
diff --git a/internal/console/controlplane_test.go b/internal/console/controlplane_test.go
index 181587124..32168b8ed 100644
--- a/internal/console/controlplane_test.go
+++ b/internal/console/controlplane_test.go
@@ -1278,3 +1278,86 @@ func TestControlPlaneAppRefusesAServiceDomainAttach(t *testing.T) {
}
claimOf(t, fx, "other.example.com")
}
+
+// TestControlPlaneAppRefusesAServicePathPublish is the prefix-map door's place
+// in this family. A published prefix map splits a hostname's traffic by leading
+// URL segment, and the reconcile pass admits any target route in the same
+// account — so a map on the CONTROL PLANE's own hostname hands a slice of the
+// console's URL space (`/admin`, `/api`) to whatever else that account runs.
+// That is not a decision a CI credential should be able to take.
+//
+// Service-only, like the attach above and unlike detach: a PERSON publishing a
+// prefix map for the control plane is an ordinary operator act. Only Publish
+// is exercised because it is the only role the route admits at all — an
+// instance credential never reaches the handler to be refused by this guard
+// (TestAPIDomainPathsIsPublishOnly pins that it gets a 403 from cliRoute
+// instead).
+func TestControlPlaneAppRefusesAServicePathPublish(t *testing.T) {
+ const host = "docs.example.com"
+ fx, person := cpDetachFixture(t, "code-cp-paths", host)
+ u := fx.ts.URL + "/api/cli/apps/bab/kass/domains/" + host + "/paths"
+ body := pathsBody(t, map[string]string{"paulca": "kass.carlosapp.dev"})
+
+ svc := serviceToken(t, fx.st, OpsAccountID, "ci", "kass", svcrole.Publish)
+ status, _, out := rawReq(t, http.MethodPut, u, svc, body)
+ if status != http.StatusForbidden {
+ t.Fatalf("a SERVICE publishing paths for the control plane = %d %s, want 403", status, out)
+ }
+ if !strings.Contains(out, "a person must") {
+ t.Fatalf("refusal %q does not say a person is the principal this needs", out)
+ }
+ if c := claimOf(t, fx, host); len(c.Paths) != 0 {
+ t.Fatalf("claim paths = %v, want none — a refused publish must not land", c.Paths)
+ }
+
+ // The guard sits BEFORE the hostname is normalized, so its answer cannot
+ // vary with host SHAPE: a flagged app refuses a malformed host and a valid
+ // one with the identical status and the identical body. Were the order
+ // reversed, the 400 "invalid hostname" would reach a prober first and this
+ // door would report whether a name is well-formed before reporting that
+ // the app is off limits — a small oracle, but the same class as the ones
+ // the domains family is built to close.
+ //
+ // Only shapes that actually REACH the handler are comparable here. A
+ // segment of "." or ".." is collapsed by net/http's own path cleaning
+ // before routing, so it lands on a different pattern entirely (405) and
+ // says nothing about this guard's ordering. The percent-encoded traversal
+ // does reach it, which is the case the normalization below the guard
+ // exists for.
+ for _, bad := range []string{"not a host", "no-dot", "%2e%2e%2fclaims", "UPPER.example.com."} {
+ badStatus, _, badOut := rawReq(t, http.MethodPut,
+ fx.ts.URL+"/api/cli/apps/bab/kass/domains/"+bad+"/paths", svc, body)
+ if badStatus != status || badOut != out {
+ t.Fatalf("host %q answered %d %q; the flagged-app refusal must be constant across host shapes (%d %q)",
+ bad, badStatus, badOut, status, out)
+ }
+ }
+
+ // A non-member still meets the family's byte-identical 404, never the
+ // refusal: the guard sits after domainsGate, so it is not an oracle for
+ // which accounts hold a control-plane app.
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-cp-paths-eve")
+ eveStatus, eveH, eveOut := rawReq(t, http.MethodPut, u, eve, body)
+ assertNotFound(t, "non-member path publish on a flagged app", canonicalNotFound(t), eveStatus, eveH, eveOut)
+
+ // The half that must keep working: a person publishes.
+ if status, _, out := rawReq(t, http.MethodPut, u, person, body); status != http.StatusOK {
+ t.Fatalf("a PERSON publishing paths for the control plane = %d %s, want 200", status, out)
+ }
+ if c := claimOf(t, fx, host); c.Paths["paulca"] != "kass.carlosapp.dev" {
+ t.Fatalf("claim paths = %v, want the person's publish to have landed", c.Paths)
+ }
+
+ // The control: with the flag cleared, the identical service call lands.
+ // The flag is the only variable between these two outcomes.
+ if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", false); err != nil {
+ t.Fatal(err)
+ }
+ cleared := pathsBody(t, map[string]string{"bigcorp": "kass.carlosapp.dev"})
+ if status, _, out := rawReq(t, http.MethodPut, u, svc, cleared); status != http.StatusOK {
+ t.Fatalf("a SERVICE publishing once the flag is cleared = %d %s, want 200", status, out)
+ }
+ if c := claimOf(t, fx, host); c.Paths["bigcorp"] != "kass.carlosapp.dev" {
+ t.Fatalf("claim paths = %v, want the service's publish to have landed once the flag was cleared", c.Paths)
+ }
+}
diff --git a/internal/domains/paths_test.go b/internal/domains/paths_test.go
index 4c738a4e4..7e172915b 100644
--- a/internal/domains/paths_test.go
+++ b/internal/domains/paths_test.go
@@ -116,9 +116,17 @@ func TestSetClaimPathsIsOwnerGated(t *testing.T) {
// TestSetClaimPathsRejectsUnnormalisedTarget: a target that is not a
// normalised hostname (no interior dot, wrong case, trailing dot, ...) is
-// refused with a validation error distinct from ErrHostUnavailable — this is
-// an owned, existing claim, so the failure can only be about the target
-// shape, not about who is asking or whether the host exists.
+// refused with ErrBadPathTarget — this is an owned, existing claim, so the
+// failure can only be about the target shape, not about who is asking or
+// whether the host exists.
+//
+// Asserted through the SENTINEL, not merely as "non-nil and not
+// ErrHostUnavailable". The API door answers this class as the CALLER's error
+// (422, naming the entry) rather than as a store failure (502, "try again"),
+// and errors.Is is the only thing that tells it which it has — so a lost %w
+// wrap would silently start telling the publishing platform to retry a body
+// that will fail identically forever. A non-nil check passes either way and
+// would not catch it.
func TestSetClaimPathsRejectsUnnormalisedTarget(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
@@ -128,13 +136,23 @@ func TestSetClaimPathsRejectsUnnormalisedTarget(t *testing.T) {
}); err != nil {
t.Fatal(err)
}
- _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
- map[string]string{"paulca": "notahostname"})
- if err == nil {
- t.Fatal("err = nil, want a validation error for an unnormalised target")
- }
- if errors.Is(err, ErrHostUnavailable) {
- t.Fatalf("err = %v, want a validation error, not ErrHostUnavailable", err)
+ // Every shape NormalizeAliasHost turns away, and each one is something a
+ // generated map could plausibly carry.
+ for _, target := range []string{"notahostname", "GO.tito.io", "go.tito.io.", "", "go tito io"} {
+ _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
+ map[string]string{"paulca": target})
+ if !errors.Is(err, ErrBadPathTarget) {
+ t.Fatalf("target %q: err = %v, want ErrBadPathTarget", target, err)
+ }
+ if errors.Is(err, ErrHostUnavailable) {
+ t.Fatalf("target %q: err = %v, want the target-shape error, not ErrHostUnavailable", target, err)
+ }
+ // Refused WHOLE: a map with one bad entry stores none of it.
+ if c, err := GetClaim(ctx, s, "ti.to"); err != nil {
+ t.Fatal(err)
+ } else if len(c.Paths) != 0 {
+ t.Fatalf("target %q: claim paths = %v, want none", target, c.Paths)
+ }
}
}