carlos / platform Public

console: publish a hostname's prefix map; show path routes in carlos routes

PUT .../domains/{host}/paths replaces the whole map, reusing the domains
family's auth, account scoping and byte-identical 404 discipline. Publish
scope only — deciding where an account's traffic goes is not an
instance-scoped decision.

No write verb on the CLI: the tenant's platform is the source of truth, and a
second writer would drift from it the first time anyone used it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 7a9df87a73327044e3611bc9fc7a4d655334098f parent 7c497b7
9 files changed, +633 −22
  • apimode.go +23 −7
  • internal/console/apidomainpaths_test.go +242 −0
  • internal/console/apidomains.go +141 −9
  • internal/console/apiroutes.go +43 −1
  • internal/console/apiroutes_test.go +46 −0
  • internal/console/authcli.go +14 −0
  • internal/domains/claims.go +8 −1
  • routes_cli.go +26 −1
  • routes_cli_test.go +90 −3
diff --git a/apimode.go b/apimode.go
index 59556c81b..54af7d3c4 100644
--- a/apimode.go
+++ b/apimode.go
@@ -2438,22 +2438,38 @@ type apiRouteRow struct {
Provisioned bool `json:"provisioned"`
}
-// routeList reads one app's routes on the console's own box (issue #64).
-func (s *apiSession) routeList(ctx context.Context, a apiAcct, app string) ([]apiRouteRow, error) {
+// apiRoutePathRow is one path route of the same answer: a leading URL
+// segment on Host served by the route at Target rather than by Host's own
+// route. Additive like every field above — a console that predates path
+// routing sends no `paths` key and this decodes as an empty list, which is
+// the same thing it means on a console that has one and no rows in it.
+type apiRoutePathRow struct {
+ Host string `json:"host"`
+ Prefix string `json:"prefix"`
+ Target string `json:"target"`
+}
+
+// routeList reads one app's routes on the console's own box (issue #64), and
+// the path routes hanging off them. Two returns rather than one joined
+// structure: a path row is a pointer at a route, not a kind of route, and
+// flattening them into one list would make every existing caller's loop
+// start having to ask which it was looking at.
+func (s *apiSession) routeList(ctx context.Context, a apiAcct, app string) ([]apiRouteRow, []apiRoutePathRow, error) {
status, body, err := s.getJSON(ctx, apiPath(a, app, "/routes"))
if err != nil {
- return nil, err
+ return nil, nil, err
}
if status != http.StatusOK {
- return nil, apiErrorFromBody(status, body)
+ return nil, nil, apiErrorFromBody(status, body)
}
var res struct {
- Routes []apiRouteRow `json:"routes"`
+ Routes []apiRouteRow `json:"routes"`
+ Paths []apiRoutePathRow `json:"paths"`
}
if err := json.Unmarshal(body, &res); err != nil {
- return nil, fmt.Errorf("routes: unreadable response: %w", err)
+ return nil, nil, fmt.Errorf("routes: unreadable response: %w", err)
}
- return res.Routes, nil
+ return res.Routes, res.Paths, nil
}
// apiLogsResponse is the console's logs answer (internal/console/apilogs.go)
diff --git a/internal/console/apidomainpaths_test.go b/internal/console/apidomainpaths_test.go
new file mode 100644
index 000000000..ffb39de4a
--- /dev/null
+++ b/internal/console/apidomainpaths_test.go
@@ -0,0 +1,242 @@
+package console
+
+// The prefix-map door (apidomains.go): PUT .../domains/{host}/paths, the one
+// way a claim's Paths map is written from outside a box. It is the door the
+// TENANT'S OWN PLATFORM calls — that platform regenerates the whole map from
+// its own database and publishes the result — so the properties under test
+// are, in the order they'd hurt if lost: the write is WHOLESALE (a second
+// publish replaces rather than merges, which is the only way a rollback can
+// be expressed); a caller who does not hold the claim gets the family's
+// byte-identical 404 rather than anything that distinguishes "someone else
+// holds it" from "nobody does"; the two shape refusals are 422s that leave
+// the stored map untouched, never a silently truncated write; and the door is
+// Publish-scoped, so an instance credential cannot decide where an account's
+// traffic goes.
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+func pathsURL(fx *consoleFixture, acct, app, host string) string {
+ return domainsURL(fx, acct, app) + "/" + host + "/paths"
+}
+
+// pathsBody is the door's request shape. Always an OBJECT with a paths field
+// rather than a bare map, so the body can grow a field later without every
+// existing caller's body changing meaning.
+func pathsBody(t *testing.T, paths map[string]string) string {
+ t.Helper()
+ b, err := json.Marshal(map[string]any{"paths": paths})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(b)
+}
+
+// domainPathsResp is the answer: the NORMALIZED host (the one field the
+// caller could not already know), how many prefixes were stored, and whether
+// this call is what changed them.
+type domainPathsResp struct {
+ Host string `json:"host"`
+ Prefixes int `json:"prefixes"`
+ Changed bool `json:"changed"`
+}
+
+// publishPaths PUTs one map and returns the decoded answer, failing the test
+// on any status but 200.
+func publishPaths(t *testing.T, fx *consoleFixture, token, host string, paths map[string]string) domainPathsResp {
+ t.Helper()
+ code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", host), token, pathsBody(t, paths))
+ if code != http.StatusOK {
+ t.Fatalf("publish paths on %s = %d %s, want 200", host, code, out)
+ }
+ var got domainPathsResp
+ if err := json.Unmarshal([]byte(out), &got); err != nil {
+ t.Fatalf("paths body %s: %v", out, err)
+ }
+ return got
+}
+
+// claimedHost attaches the hostname a prefix map hangs off. Every test below
+// starts from a claim this app actually holds, because this door writes no
+// claim of its own — it only ever replaces one field of an existing one.
+func claimedHost(t *testing.T, fx *consoleFixture, member, host string) {
+ t.Helper()
+ attachDomain(t, fx, member, host, "go.tito.io")
+}
+
+// TestAPIDomainPathsPublishes: the door writes the map onto the claim, and
+// the answer names the normalized host it wrote to.
+func TestAPIDomainPathsPublishes(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-paths-publish")
+ claimedHost(t, fx, member, "ti.to")
+ got := publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"})
+ if got != (domainPathsResp{Host: "ti.to", Prefixes: 1, Changed: true}) {
+ t.Fatalf("publish answer = %+v, want ti.to with one prefix, changed", got)
+ }
+ c := claimOf(t, fx, "ti.to")
+ if len(c.Paths) != 1 || c.Paths["paulca"] != "go.tito.io" {
+ t.Fatalf("claim paths = %v, want paulca -> go.tito.io", c.Paths)
+ }
+ // Republishing the identical map is the COMMON case — the caller
+ // republishes wholesale as a repair — and must not read as a change, or
+ // every repair would bump the serial and reload every edge in the fleet.
+ if again := publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}); again.Changed {
+ t.Fatalf("republishing the same map = %+v, want changed false", again)
+ }
+}
+
+// TestAPIDomainPathsReplaces is the wholesale contract: the caller
+// regenerates its whole map and publishes it, so an entry it no longer lists
+// must be GONE. Merging would make a rollback impossible to express.
+func TestAPIDomainPathsReplaces(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-paths-replace")
+ claimedHost(t, fx, member, "ti.to")
+ publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"})
+ publishPaths(t, fx, member, "ti.to", map[string]string{"bigcorp": "go.tito.io"})
+ c := claimOf(t, fx, "ti.to")
+ if _, still := c.Paths["paulca"]; still {
+ t.Fatalf("claim paths = %v, want paulca gone — the second publish replaces, it does not merge", c.Paths)
+ }
+ if len(c.Paths) != 1 || c.Paths["bigcorp"] != "go.tito.io" {
+ t.Fatalf("claim paths = %v, want bigcorp alone", c.Paths)
+ }
+}
+
+// TestAPIDomainPathsEmptyBodyClears: an empty map is a legitimate published
+// state — every account back on the origin — and is how a whole migration is
+// rolled back in one call.
+func TestAPIDomainPathsEmptyBodyClears(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-paths-clear")
+ claimedHost(t, fx, member, "ti.to")
+ publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"})
+ got := publishPaths(t, fx, member, "ti.to", map[string]string{})
+ if got != (domainPathsResp{Host: "ti.to", Prefixes: 0, Changed: true}) {
+ t.Fatalf("clear answer = %+v, want ti.to with no prefixes, changed", got)
+ }
+ if c := claimOf(t, fx, "ti.to"); len(c.Paths) != 0 {
+ t.Fatalf("claim paths = %v, want none", c.Paths)
+ }
+}
+
+// TestAPIDomainPathsRequiresThePathsField: this write is WHOLESALE, so a body
+// that names no map at all cannot be read as an empty one — a misspelled
+// field would otherwise clear every prefix on the hostname and answer 200.
+// An empty map still means "clear it"; it just has to be said.
+func TestAPIDomainPathsRequiresThePathsField(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-paths-nofield")
+ claimedHost(t, fx, member, "ti.to")
+ publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"})
+
+ for _, body := range []string{`{}`, `{"pathz":{"bigcorp":"go.tito.io"}}`, `{"paths":null}`} {
+ code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), member, body)
+ if code != http.StatusBadRequest {
+ t.Fatalf("publish %s = %d %s, want 400", body, code, out)
+ }
+ c := claimOf(t, fx, "ti.to")
+ if len(c.Paths) != 1 || c.Paths["paulca"] != "go.tito.io" {
+ t.Fatalf("claim paths after %s = %v, want the published map intact", body, c.Paths)
+ }
+ }
+}
+
+// TestAPIDomainPathsRejectsOtherAccount: a hostname another account holds
+// answers with the bytes http.NotFound writes and nothing else. Not a 403,
+// which would confirm the hostname exists; not a distinct JSON shape, which
+// would itself be the tell. The caller here is an honest owner of its OWN
+// account and app, so what refuses it is the claim's ownership check rather
+// than the membership gate above it.
+func TestAPIDomainPathsRejectsOtherAccount(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-paths-other")
+ claimedHost(t, fx, member, "ti.to")
+ // The positive control, and it is not optional: an UNREGISTERED route
+ // answers the byte-identical 404 too, so without a publish that works
+ // this test would pass against a console that has no such door at all.
+ publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"})
+
+ // A second account with an app of its own, and its owner's token.
+ now := time.Now()
+ if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(42, "shop", "bob@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ bob := cliToken(t, fx, "bob@keymail.dev", "code-dom-paths-bob")
+
+ want := canonicalNotFound(t)
+ code, h, body := rawReq(t, http.MethodPut,
+ fx.ts.URL+"/api/cli/apps/cdef/shop/domains/ti.to/paths", bob,
+ pathsBody(t, map[string]string{"bigcorp": "evil.example"}))
+ assertNotFound(t, "publishing paths onto another account's hostname", want, code, h, body)
+ // And nothing was written through it: the holder's own map is intact.
+ c := claimOf(t, fx, "ti.to")
+ if len(c.Paths) != 1 || c.Paths["paulca"] != "go.tito.io" {
+ t.Fatalf("claim paths = %v, want the holder's own map — a stranger's publish must not land", c.Paths)
+ }
+}
+
+// TestAPIDomainPathsRejectsOversize: a map above the cap is refused WHOLE. A
+// silently truncated map would route a subset of accounts and still look
+// successful, which is the worst failure this feature can have.
+func TestAPIDomainPathsRejectsOversize(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-paths-oversize")
+ claimedHost(t, fx, member, "ti.to")
+ publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"})
+
+ big := make(map[string]string, domains.MaxPathsPerClaim+1)
+ for i := 0; i <= domains.MaxPathsPerClaim; i++ {
+ big[fmt.Sprintf("acct%d", i)] = "go.tito.io"
+ }
+ code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), member, pathsBody(t, big))
+ if code != http.StatusUnprocessableEntity {
+ t.Fatalf("oversize publish = %d %s, want 422", code, out)
+ }
+ c := claimOf(t, fx, "ti.to")
+ if len(c.Paths) != 1 || c.Paths["paulca"] != "go.tito.io" {
+ t.Fatalf("claim paths = %v, want the previous map untouched", c.Paths)
+ }
+}
+
+// TestAPIDomainPathsRejectsBadPrefix: the edge matches a request's FIRST path
+// segment by equality, so a key with a slash in it could never match and must
+// never be stored. "/paulca" is the shape a caller reaches for first, so it
+// is the one worth pinning.
+func TestAPIDomainPathsRejectsBadPrefix(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-paths-badprefix")
+ claimedHost(t, fx, member, "ti.to")
+ code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), member,
+ pathsBody(t, map[string]string{"/paulca": "go.tito.io"}))
+ if code != http.StatusUnprocessableEntity {
+ t.Fatalf("leading-slash prefix = %d %s, want 422", code, out)
+ }
+ if c := claimOf(t, fx, "ti.to"); len(c.Paths) != 0 {
+ t.Fatalf("claim paths = %v, want none — a refused map must not partially land", c.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
+// beside this one for compatibility; it does not reach this one.
+func TestAPIDomainPathsIsPublishOnly(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-paths-roles")
+ claimedHost(t, fx, member, "ti.to")
+ body := pathsBody(t, map[string]string{"paulca": "go.tito.io"})
+
+ inst := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance)
+ if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), inst, body); code != http.StatusForbidden {
+ t.Fatalf("instance-role publish = %d %s, want 403", code, out)
+ }
+ pub := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Publish)
+ if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), pub, body); code != http.StatusOK {
+ t.Fatalf("publish-role publish = %d %s, want 200", code, out)
+ }
+}
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 6991285a0..4551c4e28 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -5,15 +5,15 @@
// the whole authorization; the agent's domains pass turns it into an alias
// row, on the box that actually owns the route.
//
-// Attach and detach go through domainsGate, below — the instance family's
-// principal, binding and readiness checks, but with the instance-verb
-// WORKLOAD budget swapped for a CONVERGENCE-RATE ceiling (domainsCeilingBurst,
-// spec §4a): a Pages hub with wildcard DNS makes no per-repo hostname call at
-// all, so gating attach/detach on the per-minute instance budget would uplift
-// a call that isn't the workload that budget was sized for. The exemption
-// applies to every principal, person and service alike — it was never about
-// who is calling, only about what the door is. A debugging session chasing a
-// 429 here wants domainsCeilingBurst, not cliInstanceBurst.
+// Attach, detach and the prefix-map publish go through domainsGate, below —
+// the instance family's principal, binding and readiness checks, but with the
+// instance-verb WORKLOAD budget swapped for a CONVERGENCE-RATE ceiling
+// (domainsCeilingBurst, spec §4a): a Pages hub with wildcard DNS makes no
+// per-repo hostname call at all, so gating these on the per-minute instance
+// budget would uplift a call that isn't the workload that budget was sized
+// for. The exemption applies to every principal, person and service alike —
+// it was never about who is calling, only about what the door is. A debugging
+// session chasing a 429 here wants domainsCeilingBurst, not cliInstanceBurst.
//
// The rest of the gate IS the instance family's, verbatim: the same bearer
// shapes reach it, and every refusal before the ceiling is the same
@@ -529,6 +529,138 @@ func (s *Server) delegationCNAMEDelegated(ctx context.Context, host string) bool
return strings.EqualFold(strings.TrimSuffix(got, "."), strings.TrimSuffix(want, "."))
}
+// maxPathsBody is the byte cap on a prefix-map publish, and it is the ONE
+// door in this family that cannot live inside readJSON's 8KiB: the ceiling is
+// MaxPathsPerClaim entries, each a segment of up to 255 bytes pointing at a
+// hostname of up to 253, which is a legitimate ~5MB body. Sized with headroom
+// above that rather than to it, so the refusal a caller at the ceiling meets
+// is the 422 that NAMES the cap — never a 413 whose fix ("split the request")
+// is wrong advice for a map that is wholesale by contract.
+const maxPathsBody = 8 << 20
+
+// handleAPIDomainPaths answers `PUT /api/cli/apps/{account}/{app}/domains/{host}/paths`:
+// publish the WHOLE prefix map for a hostname the app already claims.
+//
+// Wholesale by contract. The caller regenerates the map from its own database
+// and publishes the result, so this replaces rather than merges — that is what
+// makes a republish a full repair and lets a removal express a rollback. There
+// is deliberately no per-prefix add or delete verb: two ways to write the same
+// state is two ways for it to drift from the caller's own.
+//
+// It writes ONE field of an existing claim and creates nothing, which is what
+// separates it from attach: a hostname this app does not already hold has no
+// prefix map to publish, and says so with the family's neutral 404.
+func (s *Server) handleAPIDomainPaths(w http.ResponseWriter, r *http.Request) {
+ p, acct, app, ok := s.domainsGate(w, r)
+ if !ok {
+ return
+ }
+ // Straight after domainsGate, exactly as the attach door places its own:
+ // after the family's byte-identical 404, before any read or write. A
+ // prefix map on the control plane's own hostname hands a slice of the
+ // console's URL space to another route — /admin served by whatever the
+ // account points it at — and that is not something a CI credential
+ // should be able to decide. SERVICE-ONLY, and the split is attach's: a
+ // person doing it is an ordinary operator act, a machine doing it is the
+ // console's own address being carved up by something with no standing.
+ if s.refuseServiceControlPlane(w, p, acct.ID, app, "publish path routes for it") {
+ return
+ }
+ principal := auditActor(p, acct)
+ // Shape-checked BEFORE the key builder, exactly as the detach and get
+ // doors do: a {host} segment arrives percent-decoded, so
+ // "..%2f..%2finstances.json" would reach ClaimKey as a path with
+ // separators in it. There is no path through this handler on which an
+ // unnormalized host reaches the store.
+ //
+ // And before the BODY, unlike attach — whose hostname arrives in the body
+ // and cannot be checked any earlier. Here the host is in the path, so
+ // checking it first means a request naming a malformed one is refused
+ // without first buffering up to maxPathsBody of it. No neutrality cost:
+ // this check reads the URL and nothing else, so its answer cannot depend
+ // on who holds the claim.
+ host, ok := domains.NormalizeAliasHost(r.PathValue("host"))
+ if !ok {
+ jsonError(w, http.StatusBadRequest, "invalid hostname")
+ return
+ }
+ // A POINTER to the map, so that an ABSENT paths field is refused rather
+ // than read as an empty one. The distinction matters here in a way it
+ // does not on a merging door: this write is wholesale, so a body whose
+ // field name is misspelled would otherwise be an empty publish — a silent
+ // clearing of every prefix on the hostname, answered with 200. An empty
+ // map is a legitimate instruction and still means exactly that; it just
+ // has to be said.
+ var in struct {
+ Paths *map[string]string `json:"paths"`
+ }
+ if !readJSONLimit(w, r, &in, maxPathsBody) {
+ return
+ }
+ if in.Paths == nil {
+ jsonError(w, http.StatusBadRequest,
+ "send the whole prefix map as {\"paths\": {...}} — {\"paths\": {}} is how you clear it")
+ return
+ }
+ paths := *in.Paths
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ changed, err := domains.SetClaimPaths(ctx, s.cfg.Releases, host, acct.Sqid, app, paths)
+ if errors.Is(err, domains.ErrTooManyPaths) {
+ jsonError(w, http.StatusUnprocessableEntity,
+ fmt.Sprintf("a hostname may carry at most %d path prefixes", domains.MaxPathsPerClaim))
+ return
+ } else if errors.Is(err, domains.ErrBadPathPrefix) {
+ jsonError(w, http.StatusUnprocessableEntity,
+ "each path prefix must be a single bare segment: \"paulca\", not \"/paulca\"")
+ return
+ } else if errors.Is(err, domains.ErrBadPathTarget) {
+ // The caller's error, not ours, so it must not land in the 502 below
+ // telling them to retry a body that will fail identically forever.
+ // The wrapped message names the offending entry — the one thing that
+ // makes a 10,000-entry map fixable.
+ jsonError(w, http.StatusUnprocessableEntity, err.Error())
+ return
+ } else if errors.Is(err, domains.ErrHostUnavailable) {
+ // The family's byte-identical 404 discipline, copied verbatim from
+ // setDomainCapabilities (not jsonError — a distinct shape here would
+ // itself be a tell): a caller who does not hold this claim cannot
+ // tell "somebody else holds it" from "nobody has claimed it".
+ http.NotFound(w, r)
+ return
+ } else if err != nil {
+ log.Printf("console: api domains paths %s/%s %s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not update the hostname's path routes — try again")
+ return
+ }
+ if changed {
+ // The COUNT, never the map: a published map runs to MaxPathsPerClaim
+ // entries, and an audit row is a sentence an operator reads, not a
+ // copy of the payload. What the trail has to answer is "who moved
+ // this hostname's routing, and when".
+ s.audit(acct, app, principal, "domain-paths",
+ fmt.Sprintf("published %d path prefixes for %s (cli)", len(paths), host))
+ // A box-consumed write like every other claim write here: the
+ // reconcile pass derives route_paths from this field, so announce it
+ // and the flipped accounts are serving seconds after the call
+ // instead of on the hour. Gated on `changed` for the reason
+ // SetClaimPaths has a no-op check at all — the caller republishes
+ // wholesale as a repair, and a bump per republish would keep every
+ // box converging onto nothing.
+ s.bumpSerial(ctx)
+ }
+ // The normalized host is the one thing the caller could not already
+ // know; the map it just sent is not echoed back, both because it is
+ // megabytes of the caller's own bytes and because nothing here derives
+ // or alters it. `changed` is the same answer `created` and `removed`
+ // give on the doors either side of this one.
+ writeJSON(w, http.StatusOK, map[string]any{
+ "host": host,
+ "prefixes": len(paths),
+ "changed": changed,
+ })
+}
+
// 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
diff --git a/internal/console/apiroutes.go b/internal/console/apiroutes.go
index 489397f96..042c9b49b 100644
--- a/internal/console/apiroutes.go
+++ b/internal/console/apiroutes.go
@@ -63,6 +63,18 @@ type apiRoute struct {
Provisioned bool `json:"provisioned"`
}
+// apiRoutePath is one path route: a leading URL segment on Host that is
+// served by the route at Target instead of by Host's own route. It carries
+// no upstream, channel or environment of its own — it is a POINTER, and
+// everything about how that traffic is served comes from Target's ordinary
+// row above. Additive on the wire: a CLI that predates it decodes nothing
+// and lists routes exactly as it always did.
+type apiRoutePath struct {
+ Host string `json:"host"`
+ Prefix string `json:"prefix"`
+ Target string `json:"target"`
+}
+
// routeUpstream renders the one cell answering "where does this route send
// traffic": a socket wins over addresses (registry.Route's own
// precedence), a multi-upstream set is shown whole rather than reduced to
@@ -138,5 +150,35 @@ func (s *Server) handleAPIRoutes(w http.ResponseWriter, r *http.Request) {
// Sorted by host: registry order is not stable across passes, and a
// listing that reshuffles between calls is one nobody can diff.
sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host })
- writeJSON(w, http.StatusOK, map[string]any{"routes": rows})
+ // The path routes hanging off those hosts. A flipped prefix set has to be
+ // visible from the command operators already use, or a migration is
+ // invisible until something breaks.
+ //
+ // Best-effort, and NOT a reason to fail the listing: the routes are the
+ // answer, the prefixes are a detail of some of them, and a console whose
+ // route_paths table cannot be read should still say what routes exist.
+ // Same tenant boundary as the rows above, read off the row's own stamped
+ // columns — ListPaths returns every path route on this box.
+ var paths []apiRoutePath
+ if all, err := reg.ListPaths(); err != nil {
+ log.Printf("console: api routes: registry list paths: %v", err)
+ } else {
+ for _, p := range all {
+ // The host filter is what keeps this from listing a prefix whose
+ // hostname is not in the answer above: the reconcile pass only
+ // derives a row when the claim's host is a local route of the
+ // same app and account, so in practice every row passes — but a
+ // listing must not depend on another package's invariant.
+ if p.App != app || p.Account != acct.Sqid {
+ continue
+ }
+ if _, shown := routeFor[p.Host]; !shown {
+ continue
+ }
+ paths = append(paths, apiRoutePath{Host: p.Host, Prefix: p.Prefix, Target: p.Target})
+ }
+ }
+ // ListPaths already orders by host then prefix, which is the order the
+ // CLI prints them in under each host.
+ writeJSON(w, http.StatusOK, map[string]any{"routes": rows, "paths": paths})
}
diff --git a/internal/console/apiroutes_test.go b/internal/console/apiroutes_test.go
index 576c9e02c..785ebab1f 100644
--- a/internal/console/apiroutes_test.go
+++ b/internal/console/apiroutes_test.go
@@ -207,3 +207,49 @@ func TestAPIRoutesAppTokenIsRefused(t *testing.T) {
t.Fatal("an app token reached the routes door — it must not")
}
}
+
+// TestAPIRoutesListsPathRoutes: a hostname whose traffic is split by leading
+// path segment reports those prefixes, so an operator can read a migration's
+// flipped set from the command they already use. Same tenant boundary as the
+// routes beside them — a path row stamped to another account or another app
+// is another tenant's routing table.
+func TestAPIRoutesListsPathRoutes(t *testing.T) {
+ fx, token := routesFixture(t, "code-routes-paths")
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, p := range []registry.RoutePath{
+ {Host: "connect.go.tito.io", Prefix: "paulca", Target: "router-a.go.tito.io", Account: "bab", App: "titogo"},
+ {Host: "connect.go.tito.io", Prefix: "bigcorp", Target: "router-a.go.tito.io", Account: "bab", App: "titogo"},
+ // A neighbouring app's row, and another account's: the answer must
+ // carry neither, exactly as it carries neither of their routes.
+ {Host: "neighbour.example.com", Prefix: "paulca", Target: "connect.go.tito.io", Account: "bab", App: "kass"},
+ {Host: "foreign.example.com", Prefix: "paulca", Target: "foreign.example.com", Account: "zzzz", App: "titogo"},
+ } {
+ if err := reg.UpsertPath(p); err != nil {
+ t.Fatal(err)
+ }
+ }
+ status, out := getJSON(t, routesURL(fx, "titogo"), token)
+ if status != http.StatusOK {
+ t.Fatalf("GET routes = %d %v", status, out)
+ }
+ rows, ok := out["paths"].([]any)
+ if !ok {
+ t.Fatalf("response has no paths array: %v", out)
+ }
+ if len(rows) != 2 {
+ t.Fatalf("paths = %v, want exactly titogo's two rows in this account", out["paths"])
+ }
+ // ListPaths orders by host then prefix, and the CLI prints them in the
+ // order they arrive — so the order is part of the answer.
+ first, _ := rows[0].(map[string]any)
+ second, _ := rows[1].(map[string]any)
+ if str(t, first, "prefix") != "bigcorp" || str(t, second, "prefix") != "paulca" {
+ t.Fatalf("paths = %v, want them ordered by host then prefix", out["paths"])
+ }
+ if str(t, first, "host") != "connect.go.tito.io" || str(t, first, "target") != "router-a.go.tito.io" {
+ t.Fatalf("path row = %v, want the host it splits and the route it points at", first)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index cd8e3f795..08cf3c055 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -622,6 +622,20 @@ func (s *Server) registerCLIRoutes() {
s.cliRoute("GET /api/cli/apps/{account}/{app}/domains/{host}", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainGet)
s.cliRoute("POST /api/cli/apps/{account}/{app}/domains", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainAttach)
s.cliRoute("DELETE /api/cli/apps/{account}/{app}/domains/{host}", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainDetach)
+ // The prefix-map publish (path-based routing): replace the WHOLE map of
+ // leading path segments a claimed hostname splits its traffic by. The
+ // tenant's own platform is the writer and the source of truth — it
+ // regenerates the map from its database and republishes it — which is
+ // why there is no per-prefix verb and no CLI writer at all.
+ //
+ // `Publish` ONLY, and deliberately narrower than the three doors above
+ // it: those carry `Instance` for compatibility with a router that has
+ // always re-attached its hostnames, and this door has no such history to
+ // preserve. Deciding which accounts' traffic leaves the legacy app is a
+ // deployment-shaped act, and an instance-scoped token has no business
+ // making it. Being new, it is not in TestInstanceRoleMatchesTodaysAppTokenReach's
+ // legacy map either — nothing to exempt, because nothing reached it before.
+ s.cliRoute("PUT /api/cli/apps/{account}/{app}/domains/{host}/paths", svcrole.Allow(svcrole.Publish), s.handleAPIDomainPaths)
// App feature flags (apifeatures.go, #347). The read carries Instance
// because the app IS the consumer — its router reads its own switches
// with the CARLOS_INSTANCE_TOKEN, the same credential and the same
diff --git a/internal/domains/claims.go b/internal/domains/claims.go
index e8fbda706..935a8b062 100644
--- a/internal/domains/claims.go
+++ b/internal/domains/claims.go
@@ -351,6 +351,13 @@ var ErrTooManyPaths = errors.New("too many path prefixes")
// single path segment.
var ErrBadPathPrefix = errors.New("path prefix must be a single bare segment")
+// ErrBadPathTarget is returned by SetClaimPaths for a VALUE that is not a
+// normalised hostname. A sentinel rather than a bare fmt.Errorf so the API
+// door above can answer it as the caller's error (422, naming the entry) —
+// without it, the only way to tell a malformed target from a store failure
+// would be to match on the message text.
+var ErrBadPathTarget = errors.New("path target is not a normalised hostname")
+
// validPathPrefix reports whether p is a bare first path segment: non-empty,
// no slashes, and nothing that would not survive a URL path intact. The edge
// matches the request's first segment by EQUALITY, so anything that cannot be
@@ -400,7 +407,7 @@ func SetClaimPaths(ctx context.Context, s release.Store, host, account, app stri
// account owns is decided by the reconcile pass, which — unlike this
// door — can see the registry.
if n, ok := NormalizeAliasHost(target); !ok || n != target {
- return false, fmt.Errorf("domains: path prefix %q: target %q is not a normalised hostname", prefix, target)
+ return false, fmt.Errorf("domains: path prefix %q: target %q: %w", prefix, target, ErrBadPathTarget)
}
}
for attempt := 0; ; attempt++ {
diff --git a/routes_cli.go b/routes_cli.go
index f1082b0fd..209d3adb9 100644
--- a/routes_cli.go
+++ b/routes_cli.go
@@ -32,6 +32,11 @@ traffic, which release channel it follows, and which named config
environment it is bound to — the binding that decides which secrets the
route's process is delivered (see carlos env, carlos secrets).
+A hostname whose traffic is split by leading path segment lists those
+prefixes indented beneath it, naming the route each one is served by.
+Read-only: prefix maps are published wholesale by the platform that owns
+them, so there is no carlos verb that writes one.
+
Signed-in only (carlos auth login). On a box, the local listing is
"carlos instances".`
@@ -47,7 +52,7 @@ func runRoutes(args []string) {
sess := requireAPISession("routes", *consoleFlag)
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
app2 := resolvedApp(sess, acct, *app, routesUsage)
- rows, err := sess.routeList(context.Background(), acct, app2)
+ rows, paths, err := sess.routeList(context.Background(), acct, app2)
if err != nil {
log.Fatalf("routes: %v", err)
}
@@ -65,6 +70,26 @@ func runRoutes(args []string) {
// app's default bundle — so it prints as a dash rather than a
// blank cell a reader would take for missing data.
dashIfEmpty(r.Environment), dashIfEmpty(r.Status), dashIfEmpty(r.Health))
+ // Path routes indented under the hostname whose traffic they split.
+ // A flipped prefix set has to be visible from the command operators
+ // already use, or a migration is invisible until something breaks.
+ //
+ // KIND says "path" and UPSTREAM names the TARGET ROUTE rather than
+ // an address, because that is all a path row is: a pointer, whose
+ // channel, environment and health are the target's own row's, listed
+ // wherever that route appears. Dashes rather than blanks in the
+ // remaining columns, for the reason ENVIRONMENT prints one above —
+ // a blank cell reads as data this client failed to fetch.
+ //
+ // Read-only, here and everywhere in the CLI: the tenant's own
+ // platform publishes these wholesale and is their source of truth,
+ // and a second writer would drift from it the first time it was used.
+ for _, p := range paths {
+ if p.Host != r.Host {
+ continue
+ }
+ fmt.Fprintf(w, " /%s\tpath\t%s\t—\t—\t—\t—\n", p.Prefix, dashIfEmpty(p.Target))
+ }
}
w.Flush()
if shown == 0 {
diff --git a/routes_cli_test.go b/routes_cli_test.go
index 34fc47e6f..fcd6504b2 100644
--- a/routes_cli_test.go
+++ b/routes_cli_test.go
@@ -11,7 +11,18 @@ import (
// routesConsole plants a credential for an httptest console that answers
// GET /api/cli/apps (account resolution) and the routes door for titogo.
+// No `paths` key at all, which is also what a console predating path routing
+// sends — so every test using it is exercising that older shape too.
func routesConsole(t *testing.T, rows []map[string]any) string {
+ t.Helper()
+ return routesConsolePaths(t, rows, nil)
+}
+
+// routesConsolePaths is routesConsole with the path routes hanging off those
+// hosts. Split out rather than folded into every caller: the prefix map is
+// additive, and the tests that predate it must keep sending the answer that
+// predates it.
+func routesConsolePaths(t *testing.T, rows, paths []map[string]any) string {
t.Helper()
return signedInConsole(t, func(w http.ResponseWriter, r *http.Request) {
switch {
@@ -20,7 +31,11 @@ func routesConsole(t *testing.T, rows []map[string]any) string {
{"sqid": "bae", "name": "tom@keymail.dev", "role": "owner", "apps": []string{"titogo"}},
}})
case strings.HasSuffix(r.URL.Path, "/titogo/routes"):
- json.NewEncoder(w).Encode(map[string]any{"routes": rows})
+ body := map[string]any{"routes": rows}
+ if paths != nil {
+ body["paths"] = paths
+ }
+ json.NewEncoder(w).Encode(body)
default:
http.NotFound(w, r)
}
@@ -39,10 +54,15 @@ func TestRouteListReportsTheEnvironmentBinding(t *testing.T) {
if err != nil {
t.Fatalf("session: %v", err)
}
- rows, err := sess.routeList(context.Background(), apiAcct{Seg: "bae"}, "titogo")
+ rows, paths, err := sess.routeList(context.Background(), apiAcct{Seg: "bae"}, "titogo")
if err != nil {
t.Fatalf("routeList: %v", err)
}
+ // A console predating path routing sends no `paths` key at all; that
+ // must decode as "no path routes", never as an error.
+ if len(paths) != 0 {
+ t.Fatalf("paths = %v, want none from a console that sends no paths key", paths)
+ }
if len(rows) != 1 {
t.Fatalf("rows = %v, want one", rows)
}
@@ -67,7 +87,7 @@ func TestRouteListOldConsoleIsNamed(t *testing.T) {
if err != nil {
t.Fatalf("session: %v", err)
}
- if _, err := sess.routeList(context.Background(), apiAcct{Seg: "bae"}, "titogo"); err == nil {
+ if _, _, err := sess.routeList(context.Background(), apiAcct{Seg: "bae"}, "titogo"); err == nil {
t.Fatal("a 404 from the routes door must surface as an error, never as an empty list")
}
}
@@ -127,6 +147,73 @@ func TestRoutesTableCarriesEveryColumn(t *testing.T) {
}
}
+// TestRoutesShowsPathRoutesUnderTheirHost: a hostname whose traffic is split
+// by leading path segment prints those prefixes indented beneath it, naming
+// the route each one is served by. Without this the flipped set of a
+// migration is invisible from the command operators already use, and stays
+// invisible until something breaks.
+func TestRoutesShowsPathRoutesUnderTheirHost(t *testing.T) {
+ useTempCredentials(t)
+ origin := routesConsolePaths(t,
+ []map[string]any{
+ {"host": "connect.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9001",
+ "channel": "stable", "status": "ready", "health": "Running"},
+ {"host": "router-a.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9002",
+ "channel": "stable", "status": "ready", "health": "Running"},
+ },
+ []map[string]any{
+ {"host": "connect.go.tito.io", "prefix": "bigcorp", "target": "router-a.go.tito.io"},
+ {"host": "connect.go.tito.io", "prefix": "paulca", "target": "router-a.go.tito.io"},
+ })
+ var out bytes.Buffer
+ old := stdout
+ stdout = &out
+ t.Cleanup(func() { stdout = old })
+
+ runRoutes([]string{"-app", "titogo", "-account-id", "bae", "-console", origin})
+
+ lines := strings.Split(out.String(), "\n")
+ hostAt, prefixAt := -1, map[string]int{}
+ for i, l := range lines {
+ switch {
+ case strings.HasPrefix(l, "connect.go.tito.io"):
+ hostAt = i
+ case strings.HasPrefix(strings.TrimSpace(l), "/bigcorp"):
+ prefixAt["bigcorp"] = i
+ case strings.HasPrefix(strings.TrimSpace(l), "/paulca"):
+ prefixAt["paulca"] = i
+ }
+ }
+ if hostAt < 0 || len(prefixAt) != 2 {
+ t.Fatalf("routes output has no host row with its two prefixes under it:\n%s", out.String())
+ }
+ // UNDER their own host, not appended at the end of the table: which
+ // hostname a prefix splits is the whole fact being reported.
+ for p, at := range prefixAt {
+ if at != hostAt+1 && at != hostAt+2 {
+ t.Fatalf("/%s printed at line %d, want it directly under connect.go.tito.io at %d:\n%s",
+ p, at, hostAt, out.String())
+ }
+ if !strings.HasPrefix(lines[at], " ") {
+ t.Fatalf("/%s row = %q, want it indented under its hostname", p, lines[at])
+ }
+ if !strings.Contains(lines[at], "router-a.go.tito.io") {
+ t.Fatalf("/%s row = %q, want the route it is served by named", p, lines[at])
+ }
+ }
+ // A path row is a POINTER at another route: it has no upstream, channel,
+ // environment or health of its own, and those cells must read as answers
+ // rather than as data this client failed to fetch.
+ if !strings.Contains(lines[prefixAt["paulca"]], "\u2014") {
+ t.Fatalf("path row = %q, want em dashes in the columns a pointer has none of", lines[prefixAt["paulca"]])
+ }
+ // And they are not routes: a host with only path rows under it must not
+ // make the "no routes" line disappear, nor inflate the count of what is.
+ if strings.Contains(out.String(), "no routes for titogo") {
+ t.Fatalf("routes output claims there are none:\n%s", out.String())
+ }
+}
+
// TestRoutesEmptyListSaysSo: an empty answer prints a line, not a bare
// header an operator has to interpret.
func TestRoutesEmptyListSaysSo(t *testing.T) {