carlos / platform Public

Merge pull request #386 from carlosframework/path-based-routing

Path-based routing: one hostname, many upstreams
Paul Campbell pushed by paul@keymail.dev 6a59497621dd0973bf453c9c599fe30626b11857 parent f7ac3aaparent 1323b8e
25 files changed, +5579 −51
  • apimode.go +51 −7
  • docs/superpowers/plans/2026-09-03-path-based-routing.md +1929 −0
  • docs/superpowers/specs/2026-09-02-path-based-routing-design.md +597 −0
  • domains_cli.go +69 −1
  • domains_cli_test.go +95 −0
  • internal/console/apidomainpaths_test.go +348 −0
  • internal/console/apidomains.go +169 −9
  • internal/console/apiroutes.go +56 −1
  • internal/console/apiroutes_test.go +77 −0
  • internal/console/authcli.go +24 −0
  • internal/console/controlplane_test.go +95 −0
  • internal/console/svcroutes_test.go +13 −0
  • internal/domains/aliases.go +158 −14
  • internal/domains/aliases_paths_test.go +269 −0
  • internal/domains/claimcache_test.go +2 −1
  • internal/domains/claims.go +145 −0
  • internal/domains/claims_test.go +3 −2
  • internal/domains/paths_test.go +254 −0
  • internal/edge/edge.go +246 −10
  • internal/edge/path_e2e_test.go +117 −0
  • internal/edge/path_test.go +471 −0
  • internal/registry/paths_test.go +132 −0
  • internal/registry/registry.go +115 −2
  • routes_cli.go +26 −1
  • routes_cli_test.go +118 −3

CI passed — run details

diff --git a/apimode.go b/apimode.go
index 5b0e1764e..91d190316 100644
--- a/apimode.go
+++ b/apimode.go
@@ -2481,22 +2481,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)
@@ -2711,6 +2727,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/docs/superpowers/plans/2026-09-03-path-based-routing.md b/docs/superpowers/plans/2026-09-03-path-based-routing.md
new file mode 100644
index 000000000..0c034aeed
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-03-path-based-routing.md
@@ -0,0 +1,1929 @@
+# Path-Based Routing Implementation Plan
+
+> **HISTORICAL ARTIFACT — superseded by the code that shipped.** This plan was
+> written before implementation and is kept for the reasoning, not as a
+> description of the system. Its code blocks are NOT what shipped: implementers
+> found at least three defects in them (a missing serial bump on the write
+> door, a validation ordering in `SetClaimPaths`, and a wire shape the door
+> tightened to `{"paths": {...}}`). Read the design spec
+> (`docs/superpowers/specs/2026-09-02-path-based-routing-design.md`) for intent
+> and the source for behaviour; do not copy anything from here.
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let one hostname route to different upstreams by leading URL path segment, so Tito can migrate accounts from classic to Tito Go one at a time and reverse any of them.
+
+**Architecture:** A prefix *selects an existing route* rather than defining a backend — the same pointer shape `route_aliases` already uses. Tito publishes the whole prefix map for a hostname as an additive `paths` field on that hostname's existing claim object (`domains/<host>.json`); `ReconcileAliases` folds it into a new pass-owned `route_paths` table; `Fingerprint()` gains a section for that table so the edge's 2s watcher notices; the edge resolves the host exactly as today and then, only for hosts that have a prefix map, does one more lookup.
+
+**Tech Stack:** Go, SQLite (modernc `sqlite` driver), `net/http`, `net/http/httptest`. No new dependencies.
+
+**Spec:** `docs/superpowers/specs/2026-09-02-path-based-routing-design.md`
+
+## Global Constraints
+
+- **Additive only.** Every existing deployment has zero `route_paths` rows and must behave byte-for-byte as it does today. No migration step, no behaviour change until a row exists.
+- **Prefixes are bare segments** — `paulca`, never `/paulca` — on the wire, in the table, and in the lookup. Surfaces may render a slash; nothing else adds one.
+- **No prefix stripping.** The upstream receives the original path unchanged.
+- **`maxPathsPerClaim = 10000`.** Deterministic drops in sorted key order, logged, never silent.
+- **Tito is the only writer.** All CARLOS-side surfaces are read-only.
+- **CARLOS never learns Tito's account model.** No reserved-prefix list, no account validation, no URL-shape assumptions beyond "first segment".
+- **AGENTS.md rules apply:** `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .` all clean before every commit. Every change lands through a PR — never a direct commit or local merge to `main`.
+- **Failure direction:** when anything is missing or refused, traffic falls through to the host's own route (classic). A broken flip must never 404; it must un-flip.
+
+---
+
+### Task 1: `route_paths` table, accessors, and the fingerprint section
+
+**Files:**
+- Modify: `internal/registry/registry.go` (schema in `migrate()` ~line 395; new `RoutePath` type and accessors after `DeleteAlias` ~line 1533; `Fingerprint()` ~line 1676)
+- Test: `internal/registry/paths_test.go` (create)
+
+**Interfaces:**
+- Consumes: nothing (first task).
+- Produces: `registry.RoutePath{Host, Prefix, Target, Account, App string}`; `(*Registry).ListPaths() ([]RoutePath, error)`; `(*Registry).UpsertPath(RoutePath) error`; `(*Registry).DeletePath(host, prefix string) error`. Task 3 writes through these, Task 4 reads through `ListPaths`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/registry/paths_test.go`:
+
+```go
+package registry
+
+import "testing"
+
+// TestPathRoundTrip: a written prefix row comes back, and a repoint of the
+// same (host, prefix) replaces rather than duplicates.
+func TestPathRoundTrip(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.UpsertPath(RoutePath{
+ Host: "ti.to", Prefix: "paulca", Target: "go.tito.io",
+ Account: "tito", App: "classic",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.ListPaths()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("ListPaths returned %d rows, want 1", len(got))
+ }
+ if got[0].Target != "go.tito.io" {
+ t.Fatalf("target = %q, want go.tito.io", got[0].Target)
+ }
+
+ if err := reg.UpsertPath(RoutePath{
+ Host: "ti.to", Prefix: "paulca", Target: "beta.tito.io",
+ Account: "tito", App: "classic",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ got, err = reg.ListPaths()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("repoint produced %d rows, want 1 — (host, prefix) is the key", len(got))
+ }
+ if got[0].Target != "beta.tito.io" {
+ t.Fatalf("target = %q, want beta.tito.io after repoint", got[0].Target)
+ }
+}
+
+// TestDeletePathIsIdempotent: deleting a row that is already gone is a
+// success, the same contract DeleteAlias carries. The reconcile pass deletes
+// unconditionally, so this must not error.
+func TestDeletePathIsIdempotent(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.DeletePath("ti.to", "nobody"); err != nil {
+ t.Fatalf("deleting an absent path row: %v", err)
+ }
+}
+
+// TestPathsAreScopedToTheirHost: two hosts may carry the same prefix without
+// colliding — the key is the pair, not the prefix.
+func TestPathsAreScopedToTheirHost(t *testing.T) {
+ reg := openTestRegistry(t)
+ for _, h := range []string{"ti.to", "other.example"} {
+ if err := reg.UpsertPath(RoutePath{
+ Host: h, Prefix: "paulca", Target: "go.tito.io",
+ Account: "tito", App: "classic",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := reg.ListPaths()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("got %d rows, want 2 — (host, prefix) is the key, not prefix alone", len(got))
+ }
+}
+
+// TestFingerprintChangesOnPathMutation is the load-bearing test of this
+// task. Fingerprint() is the 2s content hash the edge polls; a route_paths
+// change it cannot see is a flip that reconciles perfectly and never reaches
+// the live map — a silent false negative, the failure class Fingerprint's own
+// header names as the one to engineer against.
+func TestFingerprintChangesOnPathMutation(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.UpsertPath(RoutePath{
+ Host: "ti.to", Prefix: "paulca", Target: "go.tito.io",
+ Account: "tito", App: "classic",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ before, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.UpsertPath(RoutePath{
+ Host: "ti.to", Prefix: "paulca", Target: "beta.tito.io",
+ Account: "tito", App: "classic",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ after, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before == after {
+ t.Fatalf("fingerprint unchanged across a route_paths repoint: %q", after)
+ }
+}
+
+// TestFingerprintChangesOnPathRemoval: the un-flip direction. Rollback is a
+// row disappearing, and it must reach the edge on the same 2s bound as the
+// flip did.
+func TestFingerprintChangesOnPathRemoval(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.UpsertPath(RoutePath{
+ Host: "ti.to", Prefix: "paulca", Target: "go.tito.io",
+ Account: "tito", App: "classic",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ before, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.DeletePath("ti.to", "paulca"); err != nil {
+ t.Fatal(err)
+ }
+ after, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before == after {
+ t.Fatalf("fingerprint unchanged across a route_paths removal: %q", after)
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `go test ./internal/registry/ -run 'TestPath|TestDeletePath|TestFingerprintChangesOnPath' -count=1`
+Expected: FAIL — `undefined: RoutePath`, `reg.UpsertPath undefined`.
+
+- [ ] **Step 3: Add the table to `migrate()`**
+
+In `internal/registry/registry.go`, immediately after the `wildcard_domains` `CREATE TABLE IF NOT EXISTS` block:
+
+```go
+ // route_paths is a derived table (the domains reconcile pass owns every
+ // row, materializing a claim's Paths map into it) — a plain CREATE TABLE
+ // IF NOT EXISTS, the same additive shape as route_aliases and
+ // wildcard_domains above, so existing DBs migrate on open with zero rows
+ // and no behaviour change.
+ //
+ // The key is the PAIR: `routes` has host as its primary key and must
+ // stay one row per hostname (that row owns the cert, the home box, the
+ // DNS state and the aliases), so a hostname's prefixes live here instead.
+ // prefix is a BARE segment — "paulca", never "/paulca".
+ if _, err := r.sql.Exec(`
+CREATE TABLE IF NOT EXISTS route_paths (
+ host TEXT NOT NULL,
+ prefix TEXT NOT NULL,
+ target TEXT NOT NULL,
+ account TEXT NOT NULL DEFAULT '',
+ app TEXT NOT NULL DEFAULT '',
+ PRIMARY KEY (host, prefix)
+);`); err != nil {
+ return err
+ }
+```
+
+- [ ] **Step 4: Add the type and accessors**
+
+In `internal/registry/registry.go`, after `DeleteAlias` (~line 1533):
+
+```go
+// RoutePath is one derived row of the route_paths table: a leading URL path
+// segment on Host that is served by the route at Target instead of by Host's
+// own route. Wholly pass-owned, like route_aliases and wildcard_domains:
+// every row traces to a claim's Paths entry, so removal needs no ownership
+// flag.
+//
+// A path row deliberately carries NO upstream, channel or environment of its
+// own. It is a pointer; everything about how the traffic is served comes from
+// Target's ordinary route row. That is what makes pointing a subset of
+// prefixes at a second route a working canary rather than a special case.
+//
+// Prefix is a bare segment — "paulca", never "/paulca".
+type RoutePath struct {
+ Host string
+ Prefix string
+ Target string
+ Account string
+ App string
+}
+
+// ListPaths returns every path route, ordered by host then prefix.
+func (r *Registry) ListPaths() ([]RoutePath, error) {
+ rows, err := r.sql.Query(`SELECT host, prefix, target, account, app
+FROM route_paths ORDER BY host, prefix`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []RoutePath
+ for rows.Next() {
+ var p RoutePath
+ if err := rows.Scan(&p.Host, &p.Prefix, &p.Target, &p.Account, &p.App); err != nil {
+ return nil, err
+ }
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
+
+// UpsertPath inserts or repoints one path route by its (host, prefix) key.
+// Unlike UpsertAlias there are no stamp columns to preserve — nothing on this
+// table is maintained on a separate cadence — so every column is overwritten.
+func (r *Registry) UpsertPath(p RoutePath) error {
+ _, err := r.sql.Exec(`
+INSERT INTO route_paths (host, prefix, target, account, app)
+VALUES (?, ?, ?, ?, ?)
+ON CONFLICT(host, prefix) DO UPDATE SET
+ target=excluded.target, account=excluded.account, app=excluded.app`,
+ p.Host, p.Prefix, p.Target, p.Account, p.App)
+ return err
+}
+
+// DeletePath removes one path route. Idempotent: a row already gone is a
+// success, the same contract as DeleteAlias — the reconcile pass deletes
+// unconditionally from its own view of what is no longer desired.
+func (r *Registry) DeletePath(host, prefix string) error {
+ _, err := r.sql.Exec(`DELETE FROM route_paths WHERE host = ? AND prefix = ?`, host, prefix)
+ return err
+}
+```
+
+- [ ] **Step 5: Add the fingerprint section**
+
+In `Fingerprint()`, after the `wildcard_domains` section and before the final digest is returned:
+
+```go
+ // route_paths: which prefix on which hostname reaches which route is a
+ // routing decision in exactly the sense route_aliases' alias/host is, so
+ // it hashes here or a flip never reaches the live map. All five columns
+ // hash — unlike route_aliases there is no box-stamped, cadence-driven
+ // column on this table to exclude, so there is nothing here that could
+ // force a pointless fleet-wide reload.
+ fmt.Fprint(h, "\x1droute_paths\x1e")
+ prows, err := db.Query(`SELECT host, prefix, target, account, app
+FROM route_paths ORDER BY host, prefix`)
+ if err != nil {
+ return "", err
+ }
+ defer prows.Close()
+ for prows.Next() {
+ var host, prefix, target, account, app string
+ if err := prows.Scan(&host, &prefix, &target, &account, &app); err != nil {
+ return "", err
+ }
+ fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e", host, prefix, target, account, app)
+ }
+ if err := prows.Err(); err != nil {
+ return "", err
+ }
+```
+
+Also extend the `Fingerprint` doc comment, after the `wildcard_domains` paragraph:
+
+```go
+// route_paths is hashed for the same reload-latency reason, and it is the
+// section most worth stating explicitly: a path flip is the ONLY routing
+// change in the system whose whole observable effect is in this table. A
+// missing section here would not degrade a flip, it would make flips do
+// nothing at all — reconciled correctly, stored correctly, never served —
+// until some unrelated route write happened to move the digest.
+```
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+Run: `go test ./internal/registry/ -count=1`
+Expected: PASS, including the four new tests and every existing registry test.
+
+- [ ] **Step 7: Verify the whole build is clean**
+
+Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1`
+Expected: no output from `gofmt -l .`, all tests pass. Zero `route_paths` rows exist anywhere, so nothing else can have changed behaviour.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add internal/registry/registry.go internal/registry/paths_test.go
+git commit -m "registry: route_paths table, accessors, and fingerprint section
+
+A hostname's routes table row is its identity — cert, home box, DNS state,
+aliases — so per-prefix routing lives in its own pass-owned table keyed on
+(host, prefix), mirroring route_aliases.
+
+The fingerprint section is the load-bearing half: a path flip's entire
+observable effect is in this table, so without it flips would reconcile
+correctly and never be served.
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: `Claim.Paths` and `SetClaimPaths`
+
+**Files:**
+- Modify: `internal/domains/claims.go` (`Claim` struct ~line 34; new `SetClaimPaths` after `SetClaimCapabilities` ~line 317)
+- Test: `internal/domains/paths_test.go` (create)
+
+**Interfaces:**
+- Consumes: nothing from Task 1 (independent; may be built in parallel).
+- Produces: `Claim.Paths map[string]string`; `domains.SetClaimPaths(ctx context.Context, s release.Store, host, account, app string, paths map[string]string) (changed bool, err error)`; `domains.ErrTooManyPaths`; `domains.MaxPathsPerClaim`. Task 3 reads `Claim.Paths`; Task 5 calls `SetClaimPaths`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/domains/paths_test.go`. Use whatever in-memory `release.Store` fake the existing `internal/domains` tests use — check `internal/domains/claims_test.go` for its constructor and reuse it verbatim rather than writing a second one.
+
+```go
+package domains
+
+import (
+ "context"
+ "errors"
+ "strconv"
+ "testing"
+)
+
+// TestSetClaimPathsRoundTrip: the map written is the map read back.
+func TestSetClaimPathsRoundTrip(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t) // see claims_test.go for the existing fake
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
+ map[string]string{"paulca": "go.tito.io"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !changed {
+ t.Fatal("changed = false on the first write of a paths map")
+ }
+ c, err := GetClaim(ctx, s, "ti.to")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.Paths["paulca"] != "go.tito.io" {
+ t.Fatalf("paths = %v, want paulca -> go.tito.io", c.Paths)
+ }
+}
+
+// TestSetClaimPathsIsWholesale: publishing is a REPLACE, not a merge. Tito
+// regenerates the whole map from its own database, so an account it no longer
+// lists must disappear — that is how a rollback reaches the edge.
+func TestSetClaimPathsIsWholesale(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
+ map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
+ map[string]string{"paulca": "go.tito.io"}); err != nil {
+ t.Fatal(err)
+ }
+ c, err := GetClaim(ctx, s, "ti.to")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, still := c.Paths["bigcorp"]; still {
+ t.Fatal("bigcorp survived a republish that omitted it — publishing must replace, not merge")
+ }
+}
+
+// TestSetClaimPathsEmptyClearsTheMap: the final cutover. When every account
+// is across, Tito repoints the host's own route and publishes an empty map.
+func TestSetClaimPathsEmptyClearsTheMap(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
+ map[string]string{"paulca": "go.tito.io"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", nil); err != nil {
+ t.Fatal(err)
+ }
+ c, err := GetClaim(ctx, s, "ti.to")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(c.Paths) != 0 {
+ t.Fatalf("paths = %v, want empty after publishing an empty map", c.Paths)
+ }
+}
+
+// TestSetClaimPathsIsOwnerGated: a caller who does not hold the claim gets
+// the same neutral ErrHostUnavailable the attach door uses — no existence
+// oracle, byte-identical to the answer for a claim that does not exist.
+func TestSetClaimPathsIsOwnerGated(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "someone-else", "classic",
+ map[string]string{"paulca": "evil.example"}); !errors.Is(err, ErrHostUnavailable) {
+ t.Fatalf("err = %v, want ErrHostUnavailable for a non-owner", err)
+ }
+ if _, err := SetClaimPaths(ctx, s, "never-claimed.example", "tito", "classic",
+ map[string]string{"a": "b"}); !errors.Is(err, ErrHostUnavailable) {
+ t.Fatalf("err = %v, want the identical ErrHostUnavailable for an absent claim", err)
+ }
+}
+
+// TestSetClaimPathsUnchangedIsNoWrite: republishing an identical map must not
+// burn a bucket write. Tito republishes wholesale as a repair, so the no-op
+// case is the common one.
+func TestSetClaimPathsUnchangedIsNoWrite(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ m := map[string]string{"paulca": "go.tito.io"}
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m); err != nil {
+ t.Fatal(err)
+ }
+ changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if changed {
+ t.Fatal("changed = true for an identical republish — a no-op must not write")
+ }
+}
+
+// TestSetClaimPathsRefusesOversizeMap: the cap is enforced at the door, so an
+// oversize map is refused whole rather than silently truncated into the
+// bucket. (The reconcile pass caps again, defensively, for hand-written
+// claims — see Task 3.)
+func TestSetClaimPathsRefusesOversizeMap(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ big := make(map[string]string, MaxPathsPerClaim+1)
+ for i := 0; i <= MaxPathsPerClaim; i++ {
+ big["acct"+strconv.Itoa(i)] = "go.tito.io"
+ }
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", big); !errors.Is(err, ErrTooManyPaths) {
+ t.Fatalf("err = %v, want ErrTooManyPaths", err)
+ }
+}
+
+// TestSetClaimPathsRejectsMalformedPrefixes: a prefix is a bare segment. A
+// leading slash, an embedded slash, or an empty key would each produce a row
+// the edge could never match, so they are refused at the door rather than
+// stored as dead weight.
+func TestSetClaimPathsRejectsMalformedPrefixes(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ for _, bad := range []string{"/paulca", "paulca/awesomeconf", "", "with space"} {
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
+ map[string]string{bad: "go.tito.io"}); !errors.Is(err, ErrBadPathPrefix) {
+ t.Fatalf("prefix %q: err = %v, want ErrBadPathPrefix", bad, err)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `go test ./internal/domains/ -run TestSetClaimPaths -count=1`
+Expected: FAIL — `undefined: SetClaimPaths`, `undefined: MaxPathsPerClaim`.
+
+- [ ] **Step 3: Add the `Paths` field to `Claim`**
+
+In `internal/domains/claims.go`, inside the `Claim` struct, after `Catchall`:
+
+```go
+ // Paths splits this hostname's traffic by leading URL segment: a bare
+ // first segment ("paulca", never "/paulca") mapped to the HOSTNAME of the
+ // route that serves it. Absent or empty means this host routes by
+ // hostname alone — the behaviour of every host today, and the reason this
+ // field is additive in the strongest sense: no row, no map, no change.
+ //
+ // This is a THIRD claim capability alongside Wildcard/Catchall, and it is
+ // declared here for the reason stated above them — converge preserves only
+ // DECLARED fields, so an undeclared field would be stripped by any binary
+ // that rewrote the claim.
+ //
+ // The writer is the tenant's own platform, publishing the whole map
+ // regenerated from its own database. Deltas were rejected deliberately: a
+ // lost delta desyncs the two systems silently, whereas a wholesale
+ // republish is always a full repair.
+ Paths map[string]string `json:"paths,omitempty"`
+```
+
+- [ ] **Step 4: Add the errors, the cap, and `SetClaimPaths`**
+
+In `internal/domains/claims.go`, after `SetClaimCapabilities`:
+
+```go
+// MaxPathsPerClaim bounds a claim's prefix map. Unlike maxWildcardDomains
+// this defends no external budget — there is no ACME equivalent for a path
+// row — it bounds the claim object's size and the per-box fold that reads it
+// every fast-converge tick. At roughly 40 bytes an entry this is a ~400KB
+// object refolded every couple of seconds, which is comfortable; sharding a
+// host's map across objects is deliberately not built until a real host
+// approaches this number.
+const MaxPathsPerClaim = 10000
+
+// ErrTooManyPaths is returned by SetClaimPaths for a map above
+// MaxPathsPerClaim. Refused whole: a silently truncated map would route a
+// subset of accounts and look successful.
+var ErrTooManyPaths = errors.New("too many path prefixes")
+
+// ErrBadPathPrefix is returned by SetClaimPaths for a key that is not a bare
+// single path segment.
+var ErrBadPathPrefix = errors.New("path prefix must be a single bare segment")
+
+// 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
+// a first segment could never match and must not be stored.
+func validPathPrefix(p string) bool {
+ if p == "" || len(p) > 255 {
+ return false
+ }
+ for i := 0; i < len(p); i++ {
+ c := p[i]
+ switch {
+ case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
+ case c == '-' || c == '_' || c == '.' || c == '~':
+ default:
+ return false
+ }
+ }
+ // "." and ".." would be normalised away by any path cleaner between the
+ // browser and here, so a row keyed on either is unreachable by
+ // construction.
+ return p != "." && p != ".."
+}
+
+// SetClaimPaths is the ONLY writer of a claim's Paths field, the same
+// single-writer discipline SetClaimCapabilities holds over Wildcard/Catchall
+// and for the same reason: AttachClaim's converge repoints Route and nothing
+// else, so it can preserve this map but must never invent or strip it.
+//
+// The write is WHOLESALE — paths replaces the stored map rather than merging
+// into it. That is the contract the caller's platform relies on: it
+// regenerates the map from its own database and publishes the result, so an
+// entry it no longer lists must disappear. Merging would make rollback
+// impossible to express.
+//
+// Owner-gated exactly as SetClaimCapabilities is: a caller who does not hold
+// the claim gets the neutral ErrHostUnavailable, and a missing claim answers
+// identically, so this door is not an existence oracle.
+func SetClaimPaths(ctx context.Context, s release.Store, host, account, app string, paths map[string]string) (changed bool, err error) {
+ if len(paths) > MaxPathsPerClaim {
+ return false, ErrTooManyPaths
+ }
+ for prefix, target := range paths {
+ if !validPathPrefix(prefix) {
+ return false, ErrBadPathPrefix
+ }
+ // The target must be a plausible hostname. Whether it is a route this
+ // 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)
+ }
+ }
+ for attempt := 0; ; attempt++ {
+ cur, etag, err := getClaimETag(ctx, s, host)
+ if errors.Is(err, os.ErrNotExist) {
+ return false, ErrHostUnavailable
+ } else if err != nil {
+ return false, err
+ }
+ if cur.Account != account || cur.App != app {
+ return false, ErrHostUnavailable
+ }
+ if samePaths(cur.Paths, paths) {
+ return false, nil
+ }
+ next := *cur
+ if len(paths) == 0 {
+ next.Paths = nil // omitempty: an empty map and no map are one state
+ } else {
+ next.Paths = make(map[string]string, len(paths))
+ for k, v := range paths {
+ next.Paths[k] = v
+ }
+ }
+ body, err := json.Marshal(next)
+ if err != nil {
+ return false, err
+ }
+ if _, err := s.Put(ctx, ClaimKey(host), body, etag); err == nil {
+ return true, nil
+ } else if errors.Is(err, release.ErrCASConflict) && attempt < 3 {
+ continue
+ } else {
+ return false, err
+ }
+ }
+}
+
+// samePaths reports whether two prefix maps are equal, treating nil and empty
+// as the same state. Republishing an unchanged map is the COMMON case — the
+// caller republishes wholesale as a repair — so this is what keeps a repair
+// from costing a bucket write and a fleet-wide edge reload.
+func samePaths(a, b map[string]string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for k, v := range a {
+ if b[k] != v {
+ return false
+ }
+ }
+ return true
+}
+```
+
+Confirm `fmt` is imported in `claims.go` (it is — `getClaimETag` uses it).
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `go test ./internal/domains/ -count=1`
+Expected: PASS.
+
+- [ ] **Step 6: Verify converge round-trips the new field**
+
+`AttachClaim`'s converge rewrites a claim; the `Paths` map must survive it. Confirm by reading `AttachClaim`'s converge branch (`internal/domains/claims.go:105`) — it constructs its next value from the current claim, so a declared field is preserved. If it instead builds a fresh `Claim` literal, add `Paths` to that literal and add this test:
+
+```go
+// TestAttachConvergePreservesPaths: a repoint must not strip the prefix map.
+func TestAttachConvergePreservesPaths(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
+ map[string]string{"paulca": "go.tito.io"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti-to-v2.tito.io", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ c, err := GetClaim(ctx, s, "ti.to")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.Paths["paulca"] != "go.tito.io" {
+ t.Fatalf("paths = %v, want the map preserved across a converge repoint", c.Paths)
+ }
+}
+```
+
+Run: `go test ./internal/domains/ -count=1` — PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/domains/claims.go internal/domains/paths_test.go
+git commit -m "domains: claims carry a prefix map, written only by SetClaimPaths
+
+domains/<host>.json is already one CAS-written object per hostname carrying
+account and app — exactly the granularity and write semantics a per-host
+prefix map needs, so path routing is a third claim capability rather than a
+new control-plane object type.
+
+Writes are wholesale: the tenant's platform regenerates the map from its own
+database and publishes the result, so a republish is always a full repair and
+a lost update can never desync the two systems.
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: Derive `route_paths` in the reconcile pass
+
+**Files:**
+- Modify: `internal/domains/aliases.go` (`ReconcileAliases`, line 48; the desired-set loop and the pass-owned deletion tail ~line 190-222)
+- Test: `internal/domains/aliases_paths_test.go` (create)
+
+**Interfaces:**
+- Consumes: `registry.RoutePath`, `(*Registry).ListPaths/UpsertPath/DeletePath` (Task 1); `Claim.Paths`, `MaxPathsPerClaim` (Task 2).
+- Produces: no new exported symbols. `route_paths` rows now exist in the registry for Task 4 to read.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/domains/aliases_paths_test.go`. Reuse the store fake and the registry helper the existing `internal/domains` alias tests use — read `internal/domains/aliases_test.go` and copy its setup verbatim.
+
+```go
+package domains
+
+import (
+ "context"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// pathsByPrefix collapses ListPaths into a prefix -> target map for one host.
+func pathsByPrefix(t *testing.T, reg *registry.Registry, host string) map[string]string {
+ t.Helper()
+ rows, err := reg.ListPaths()
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := map[string]string{}
+ for _, p := range rows {
+ if p.Host == host {
+ out[p.Prefix] = p.Target
+ }
+ }
+ return out
+}
+
+// TestReconcileDerivesPathRows is the feature: a claim's Paths map becomes
+// route_paths rows.
+func TestReconcileDerivesPathRows(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ reg := newTestRegistry(t)
+ mustRoute(t, reg, "ti.to", "tito", "classic")
+ mustRoute(t, reg, "go.tito.io", "tito", "classic")
+ mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to",
+ map[string]string{"paulca": "go.tito.io"})
+
+ if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if got := pathsByPrefix(t, reg, "ti.to"); got["paulca"] != "go.tito.io" {
+ t.Fatalf("route_paths = %v, want paulca -> go.tito.io", got)
+ }
+}
+
+// TestReconcileRemovesUndesiredPathRows is the rollback path and the
+// pass-ownership property: a prefix dropped from the published map disappears
+// from the table on the next pass, with no delete verb anywhere.
+func TestReconcileRemovesUndesiredPathRows(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ reg := newTestRegistry(t)
+ mustRoute(t, reg, "ti.to", "tito", "classic")
+ mustRoute(t, reg, "go.tito.io", "tito", "classic")
+ mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to",
+ map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"})
+ if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
+ map[string]string{"paulca": "go.tito.io"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ got := pathsByPrefix(t, reg, "ti.to")
+ if _, still := got["bigcorp"]; still {
+ t.Fatalf("route_paths = %v, want bigcorp gone after it left the published map", got)
+ }
+ if got["paulca"] != "go.tito.io" {
+ t.Fatalf("route_paths = %v, want paulca still routed", got)
+ }
+}
+
+// TestReconcileRefusesCrossTenantTarget is the one genuinely new authorization
+// rule in this feature: a prefix must never point another tenant's traffic at
+// your backend.
+func TestReconcileRefusesCrossTenantTarget(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ reg := newTestRegistry(t)
+ mustRoute(t, reg, "ti.to", "tito", "classic")
+ mustRoute(t, reg, "victim.example", "someone-else", "their-app")
+ mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to",
+ map[string]string{"paulca": "victim.example"})
+
+ if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
+ t.Fatalf("route_paths = %v, want no row — the target belongs to another account", got)
+ }
+}
+
+// TestReconcileSkipsUnknownTarget: a target that is not a local route yields
+// no row, so the prefix falls through to the host's own route. That is the
+// correct failure direction for a migration — a missing Go route serves
+// classic rather than 404ing.
+func TestReconcileSkipsUnknownTarget(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ reg := newTestRegistry(t)
+ mustRoute(t, reg, "ti.to", "tito", "classic")
+ mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to",
+ map[string]string{"paulca": "not-deployed-here.example"})
+
+ if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
+ t.Fatalf("route_paths = %v, want no row for a target that is not a local route", got)
+ }
+}
+
+// TestReconcileSkipsWhenClaimHostIsNotALocalRoute: the prefix map belongs to
+// the hostname's own route. If that route lives on another box, this box
+// derives nothing — the same per-box rule the alias half already follows.
+func TestReconcileSkipsWhenClaimHostIsNotALocalRoute(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ reg := newTestRegistry(t)
+ mustRoute(t, reg, "go.tito.io", "tito", "classic")
+ mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to",
+ map[string]string{"paulca": "go.tito.io"})
+
+ if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
+ t.Fatalf("route_paths = %v, want nothing — ti.to is not a route on this box", got)
+ }
+}
+
+// TestReconcileCapsPathRows: a hand-written claim above the cap is truncated
+// deterministically (sorted key order), never silently and never at random.
+func TestReconcileCapsPathRows(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ reg := newTestRegistry(t)
+ mustRoute(t, reg, "ti.to", "tito", "classic")
+ mustRoute(t, reg, "go.tito.io", "tito", "classic")
+ big := make(map[string]string, MaxPathsPerClaim+10)
+ for i := 0; i < MaxPathsPerClaim+10; i++ {
+ big["acct"+strconv.Itoa(i)] = "go.tito.io"
+ }
+ mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to", big)
+
+ if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if got := pathsByPrefix(t, reg, "ti.to"); len(got) != MaxPathsPerClaim {
+ t.Fatalf("derived %d rows, want the cap of %d", len(got), MaxPathsPerClaim)
+ }
+}
+```
+
+Add these helpers to the same file (adapt the route/claim shapes to whatever the existing alias tests use):
+
+```go
+func mustRoute(t *testing.T, reg *registry.Registry, host, account, app string) {
+ t.Helper()
+ if err := reg.Upsert(registry.Route{
+ Host: host, Kind: "service", Addr: "127.0.0.1:1", Status: "ready",
+ Account: account, App: app,
+ }); err != nil {
+ t.Fatalf("upsert route %s: %v", host, err)
+ }
+}
+
+func mustClaimWithPaths(t *testing.T, ctx context.Context, s release.Store,
+ host, account, app, route string, paths map[string]string) {
+ t.Helper()
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: host, Account: account, App: app, Route: route,
+ CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatalf("attach claim %s: %v", host, err)
+ }
+ // Written directly rather than through SetClaimPaths so the cap test can
+ // stage an oversize map the door would refuse.
+ cur, etag, err := getClaimETag(ctx, s, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cur.Paths = paths
+ body, err := json.Marshal(cur)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(ctx, ClaimKey(host), body, etag); err != nil {
+ t.Fatal(err)
+ }
+}
+```
+
+Import `encoding/json` and `github.com/carlosframework/platform/internal/release` in the test file.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `go test ./internal/domains/ -run TestReconcile -count=1`
+Expected: FAIL — the new tests find zero derived rows.
+
+- [ ] **Step 3: Derive the desired path set**
+
+In `internal/domains/aliases.go`, add `"sort"` to the imports. Alongside `desired` and `desiredWild` near the top of `ReconcileAliases`, add:
+
+```go
+ desiredPaths := map[string]registry.RoutePath{} // key: host + "\x00" + prefix
+```
+
+Then inside the `for _, c := range claims` loop, after the wildcard derivation, add:
+
+```go
+ // Path derivation (prefix routing). A claim's Paths map splits its own
+ // hostname's traffic by leading URL segment; each entry names the
+ // hostname of the route that serves that segment.
+ //
+ // Gated on the claim's host being a LOCAL route whose tenancy matches
+ // the claim — the same per-box rule the alias half above follows, and
+ // for the same reason: the prefix map belongs to that route, so a box
+ // that does not serve the route derives nothing.
+ if len(c.Paths) > 0 {
+ hostRt, ok := byHost[c.Host]
+ if !ok {
+ // The hostname's own route is not on this box; another box
+ // derives these rows.
+ } else if hostRt.App != c.App || release.RouteAccount(hostRt) != c.Account {
+ log.Printf("domains: paths on %s refused: route belongs to %s/%s, claim says %s/%s",
+ c.Host, release.RouteAccount(hostRt), hostRt.App, c.Account, c.App)
+ } else {
+ // Sorted so the cap below truncates DETERMINISTICALLY: every
+ // box must derive the identical subset, or the same URL would
+ // resolve differently depending on which edge answered.
+ prefixes := make([]string, 0, len(c.Paths))
+ for p := range c.Paths {
+ prefixes = append(prefixes, p)
+ }
+ sort.Strings(prefixes)
+ if len(prefixes) > MaxPathsPerClaim {
+ log.Printf("domains: paths on %s: %d prefixes exceeds the cap of %d, dropping %d in sorted order",
+ c.Host, len(prefixes), MaxPathsPerClaim, len(prefixes)-MaxPathsPerClaim)
+ prefixes = prefixes[:MaxPathsPerClaim]
+ }
+ for _, prefix := range prefixes {
+ target := c.Paths[prefix]
+ if !validPathPrefix(prefix) {
+ log.Printf("domains: path %s/%s refused: not a bare path segment", c.Host, prefix)
+ continue
+ }
+ tgtRt, ok := byHost[target]
+ if !ok {
+ // Target is not a route on this box. No row, so the
+ // prefix falls through to the host's own route — the
+ // right failure direction for a migration: a missing
+ // destination serves the ORIGIN, never a 404.
+ continue
+ }
+ // The authorization rule this feature adds: a prefix must
+ // never point another tenant's traffic at your backend.
+ // Account only — an account routing between its own apps
+ // (classic and Go ARE two apps) is the whole use case.
+ if release.RouteAccount(tgtRt) != c.Account {
+ log.Printf("domains: path %s/%s refused: target %s belongs to %s, claim says %s",
+ c.Host, prefix, target, release.RouteAccount(tgtRt), c.Account)
+ continue
+ }
+ desiredPaths[c.Host+"\x00"+prefix] = registry.RoutePath{
+ Host: c.Host, Prefix: prefix, Target: target,
+ Account: c.Account, App: c.App,
+ }
+ }
+ }
+ }
+```
+
+- [ ] **Step 4: Write and prune the table**
+
+At the end of `ReconcileAliases`, after the `wildcard_domains` deletion loop and before `return nil`:
+
+```go
+ // route_paths is wholly pass-owned, like route_aliases and
+ // wildcard_domains: every row traces to a claim's Paths entry, so a
+ // prefix removed from a republished map disappears here with no delete
+ // verb anywhere. That is exactly how a per-account rollback reaches the
+ // edge.
+ existingPaths, err := reg.ListPaths()
+ if err != nil {
+ return err
+ }
+ for _, p := range desiredPaths {
+ if err := reg.UpsertPath(p); err != nil {
+ log.Printf("domains: upsert path %s/%s: %v", p.Host, p.Prefix, err)
+ }
+ }
+ for _, p := range existingPaths {
+ if _, ok := desiredPaths[p.Host+"\x00"+p.Prefix]; !ok {
+ if err := reg.DeletePath(p.Host, p.Prefix); err != nil {
+ log.Printf("domains: remove path %s/%s: %v", p.Host, p.Prefix, err)
+ }
+ }
+ }
+ return nil
+```
+
+`UpsertPath` is unconditional rather than diffed-first (the shape the wildcard half uses) because the row has five short columns and no stamps to preserve — a redundant write costs one statement and cannot lose state. `Fingerprint` hashes content, not write counts, so this does not cause spurious reloads.
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `go test ./internal/domains/ -count=1`
+Expected: PASS, including every existing alias and wildcard test — the pass's existing behaviour must be untouched.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/domains/aliases.go internal/domains/aliases_paths_test.go
+git commit -m "domains: derive route_paths from claims, gated on same-account targets
+
+Extends the pass that already materializes route_aliases and
+wildcard_domains, keeping the same wholly-pass-owned property: a prefix
+dropped from a republished map disappears on the next tick, so rollback needs
+no delete verb.
+
+Adds one authorization rule — a prefix's target must belong to the claim's
+account — so a prefix can never point another tenant's traffic at your
+backend. A target that is not a local route yields no row, so the prefix falls
+through to the hostname's own route: a missing destination serves the origin,
+never a 404.
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: The edge resolves prefixes
+
+**Files:**
+- Modify: `internal/edge/edge.go` (`upstream` struct line 244; `Reload`'s alias fold ~line 591-606; `lookupUpstream` line 866; `ServeHTTP` dispatch line 930)
+- Test: `internal/edge/path_test.go` (create)
+
+**Interfaces:**
+- Consumes: `registry.RoutePath`, `(*Registry).ListPaths` (Task 1); `route_paths` rows (Task 3).
+- Produces: `upstream.paths map[string]string`; `(*Edge).lookupUpstreamPath(host, path string) (upstream, bool)`; `lookupIn(m map[string]upstream, host string) (upstream, bool)`. Nothing outside `internal/edge` consumes these.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/edge/path_test.go`:
+
+```go
+package edge
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// upsertPath writes one route_paths row, the shape the domains reconcile pass
+// derives from a claim's Paths map.
+func upsertPath(t *testing.T, reg *registry.Registry, host, prefix, target string) {
+ t.Helper()
+ if err := reg.UpsertPath(registry.RoutePath{
+ Host: host, Prefix: prefix, Target: target, Account: "acct", App: "app",
+ }); err != nil {
+ t.Fatalf("upsert path %s/%s -> %s: %v", host, prefix, target, err)
+ }
+}
+
+// splitHost stands up one hostname whose default route is "classic" and whose
+// "flipped" prefix reaches a second route, returning the live test server.
+func splitHost(t *testing.T) *httptest.Server {
+ t.Helper()
+ classic, _ := hostEchoBackend(t, "classic")
+ golive, _ := hostEchoBackend(t, "go")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "ti.to", Kind: "service",
+ Addr: classic.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "go.tito.io", Kind: "service",
+ Addr: golive.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ upsertPath(t, reg, "ti.to", "flipped", "go.tito.io")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+// TestPathRouteReachesTarget is the feature: a flipped prefix reaches the
+// other route's backend while the same hostname's other traffic does not.
+func TestPathRouteReachesTarget(t *testing.T) {
+ srv := splitHost(t)
+ for _, tc := range []struct{ path, want string }{
+ {"/flipped", "go"},
+ {"/flipped/awesomeconf", "go"},
+ {"/flipped/awesomeconf/admin", "go"},
+ {"/notflipped", "classic"},
+ {"/notflipped/event", "classic"},
+ {"/", "classic"},
+ {"", "classic"},
+ } {
+ code, body := getHostPath(t, srv.URL, "ti.to", tc.path)
+ if code != http.StatusOK {
+ t.Fatalf("%s: status = %d, want 200", tc.path, code)
+ }
+ if body != tc.want {
+ t.Fatalf("%s: body = %q, want %q", tc.path, body, tc.want)
+ }
+ }
+}
+
+// TestPathMatchIsSegmentBounded is the boundary pin. A prefix match must be
+// segment-wise equality, never a string prefix — "flippedxyz" is a different
+// account from "flipped" and must not be dragged across the migration with it.
+func TestPathMatchIsSegmentBounded(t *testing.T) {
+ srv := splitHost(t)
+ for _, p := range []string{"/flippedxyz", "/flipped-two", "/flippedxyz/event"} {
+ code, body := getHostPath(t, srv.URL, "ti.to", p)
+ if code != http.StatusOK {
+ t.Fatalf("%s: status = %d, want 200", p, code)
+ }
+ if body != "classic" {
+ t.Fatalf("%s: body = %q, want classic — a prefix matches whole segments only", p, body)
+ }
+ }
+}
+
+// TestPathRouteDoesNotStripThePrefix: the upstream sees the original URL. The
+// flipped and unflipped URL spaces must be identical, which is the whole
+// point of a seamless migration.
+func TestPathRouteDoesNotStripThePrefix(t *testing.T) {
+ classic, _ := hostEchoBackend(t, "classic")
+ golive := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(r.URL.Path))
+ }))
+ t.Cleanup(golive.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "ti.to", Kind: "service",
+ Addr: classic.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "go.tito.io", Kind: "service",
+ Addr: golive.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ upsertPath(t, reg, "ti.to", "paulca", "go.tito.io")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf")
+ if body != "/paulca/awesomeconf" {
+ t.Fatalf("upstream saw %q, want /paulca/awesomeconf — the prefix must NOT be stripped", body)
+ }
+}
+
+// TestPathPreservesInboundHost: the upstream sees the hostname the browser
+// asked for, matching the alias contract. Tito Go must be able to build
+// absolute URLs that keep the visitor on ti.to.
+func TestPathPreservesInboundHost(t *testing.T) {
+ classic, _ := hostEchoBackend(t, "classic")
+ golive, seenHost := hostEchoBackend(t, "go")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "ti.to", Kind: "service",
+ Addr: classic.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "go.tito.io", Kind: "service",
+ Addr: golive.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ upsertPath(t, reg, "ti.to", "paulca", "go.tito.io")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ getHostPath(t, srv.URL, "ti.to", "/paulca")
+ if *seenHost != "ti.to" {
+ t.Fatalf("upstream saw Host %q, want ti.to", *seenHost)
+ }
+}
+
+// TestPathRowForMissingTargetFallsThrough: a row whose target is not in the
+// live map must serve the hostname's own route, never 404. This is the
+// failure direction the whole design depends on.
+func TestPathRowForMissingTargetFallsThrough(t *testing.T) {
+ classic, _ := hostEchoBackend(t, "classic")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "ti.to", Kind: "service",
+ Addr: classic.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ upsertPath(t, reg, "ti.to", "paulca", "vanished.example")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ code, body := getHostPath(t, srv.URL, "ti.to", "/paulca")
+ if code != http.StatusOK || body != "classic" {
+ t.Fatalf("status=%d body=%q, want 200/classic — a dangling target must fall through", code, body)
+ }
+}
+
+// TestHostsWithoutPathsAreUnaffected: the additive guarantee. Every host on
+// the platform today has no prefix map and must behave exactly as before.
+func TestHostsWithoutPathsAreUnaffected(t *testing.T) {
+ backend, _ := hostEchoBackend(t, "plain")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "app.acct.oncarlos.com", Kind: "service",
+ Addr: backend.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ for _, p := range []string{"/", "/anything", "/deep/path/here"} {
+ code, body := getHostPath(t, srv.URL, "app.acct.oncarlos.com", p)
+ if code != http.StatusOK || body != "plain" {
+ t.Fatalf("%s: status=%d body=%q, want 200/plain", p, code, body)
+ }
+ }
+}
+
+// TestFirstSegment is the matcher itself, tested directly because every
+// routing decision in this feature rests on it.
+func TestFirstSegment(t *testing.T) {
+ for _, tc := range []struct{ in, want string }{
+ {"/paulca", "paulca"},
+ {"/paulca/", "paulca"},
+ {"/paulca/awesomeconf", "paulca"},
+ {"/paulca/awesomeconf/admin", "paulca"},
+ {"/", ""},
+ {"", ""},
+ {"paulca", ""}, // not an absolute path; never matches
+ {"//", ""},
+ } {
+ if got := firstSegment(tc.in); got != tc.want {
+ t.Fatalf("firstSegment(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
+```
+
+Add the request helper to the same file if `getHost` in `alias_test.go` does not already take a path — check first and reuse it if it does:
+
+```go
+// getHostPath issues GET path against srv with an explicit Host header.
+func getHostPath(t *testing.T, srvURL, host, path string) (int, string) {
+ t.Helper()
+ req, err := http.NewRequest("GET", srvURL+path, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Host = host
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ b, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return resp.StatusCode, string(b)
+}
+```
+
+(Import `io`.)
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `go test ./internal/edge/ -run 'TestPath|TestFirstSegment|TestHostsWithoutPaths' -count=1`
+Expected: FAIL — `undefined: firstSegment`, and the routing tests serve "classic" for `/flipped`.
+
+- [ ] **Step 3: Add the `paths` field to `upstream`**
+
+In `internal/edge/edge.go`, inside the `upstream` struct after `alias`:
+
+```go
+ // paths, when non-nil, splits this hostname's traffic by leading URL
+ // segment: a bare first segment ("paulca") mapped to the HOSTNAME of the
+ // route that serves it, resolved against the same live map at request
+ // time. Nil for every host that does not use prefix routing, which is
+ // every host on the platform until a claim publishes a map — the nil
+ // check in lookupUpstreamPath is the entire cost this feature imposes on
+ // everyone else.
+ //
+ // Hostnames rather than resolved upstreams, deliberately: an upstream
+ // captured here would be a stale copy of the target's entry the moment
+ // the target's route changed within the same map build.
+ paths map[string]string
+```
+
+- [ ] **Step 4: Fold `route_paths` into `Reload`**
+
+In `Reload`, immediately BEFORE the `aliases, err := e.reg.ListAliases()` block (line 591):
+
+```go
+ // Path routes, folded BEFORE aliases so an alias of a prefix-routed
+ // hostname inherits its map along with everything else it copies — an
+ // alias is meant to be indistinguishable from its primary, and a
+ // hostname that routed by prefix under one name but not the other would
+ // be a trap.
+ //
+ // Targets are recorded as hostnames, not resolved here: every route is
+ // already in m by this point, but resolving now would freeze a copy of
+ // the target's entry, and the target's own row may still be rewritten by
+ // the alias fold below.
+ paths, err := e.reg.ListPaths()
+ if err != nil {
+ return err
+ }
+ for _, p := range paths {
+ up, ok := m[p.Host]
+ if !ok {
+ // The hostname has no route on this box. The reconcile pass
+ // should not have produced this row; nothing to attach it to.
+ continue
+ }
+ if up.paths == nil {
+ up.paths = make(map[string]string)
+ }
+ up.paths[p.Prefix] = p.Target
+ m[p.Host] = up
+ }
+```
+
+- [ ] **Step 5: Split the lookup and add the path-aware form**
+
+Replace `lookupUpstream` (line 866) with the pair below. The existing doc comment above it stays exactly as it is — it describes `lookupIn`'s wildcard rule, which is unchanged — so move it onto `lookupIn`.
+
+```go
+func lookupIn(m map[string]upstream, host string) (upstream, bool) {
+ if up, ok := m[host]; ok {
+ return up, true
+ }
+ if i := strings.IndexByte(host, '.'); i > 0 {
+ if up, ok := m["*"+host[i:]]; ok {
+ return up, true
+ }
+ }
+ return upstream{}, false
+}
+
+// lookupUpstream resolves a hostname alone. Kept as the primitive because not
+// every caller is answering "where does this REQUEST go" — the routability
+// check in the ACME/HostPolicy path asks only whether this box serves the
+// hostname at all, and giving that question a path would make a hostname's
+// routability depend on which URL happened to ask.
+func (e *Edge) lookupUpstream(host string) (upstream, bool) {
+ return lookupIn(e.current(), host)
+}
+
+// lookupUpstreamPath resolves a request: the hostname exactly as
+// lookupUpstream does, then — only for a hostname carrying a prefix map — the
+// first path segment.
+//
+// Both lookups read ONE snapshot of the map. Calling lookupUpstream and then
+// current() again would let a reload land between them and resolve a prefix
+// against a different table than the host came from.
+//
+// Every miss falls through to the hostname's own route rather than failing:
+// an unlisted prefix, a dangling target, a request for "/". A prefix map is a
+// migration in progress, and the un-migrated answer is always the safe one.
+func (e *Edge) lookupUpstreamPath(host, path string) (upstream, bool) {
+ m := e.current()
+ up, ok := lookupIn(m, host)
+ if !ok || up.paths == nil {
+ return up, ok
+ }
+ seg := firstSegment(path)
+ if seg == "" {
+ return up, true
+ }
+ target, ok := up.paths[seg]
+ if !ok {
+ return up, true
+ }
+ tu, ok := m[target]
+ if !ok {
+ return up, true
+ }
+ return tu, true
+}
+
+// firstSegment returns the first path segment of an absolute URL path,
+// without slashes: "/paulca/awesomeconf" -> "paulca". "" for "/", for the
+// empty string, and for anything not starting with "/".
+//
+// Returning the segment rather than testing a string prefix is what makes
+// matching segment-bounded: "/paulcarson" yields "paulcarson", which is not
+// equal to "paulca", so a longer account name can never be dragged across a
+// migration by a shorter one.
+func firstSegment(p string) string {
+ if p == "" || p[0] != '/' {
+ return ""
+ }
+ p = p[1:]
+ if i := strings.IndexByte(p, '/'); i >= 0 {
+ p = p[:i]
+ }
+ return p
+}
+```
+
+- [ ] **Step 6: Use the path-aware form in `ServeHTTP`**
+
+At `internal/edge/edge.go:930`, change:
+
+```go
+ up, ok := e.lookupUpstream(host)
+```
+
+to:
+
+```go
+ up, ok := e.lookupUpstreamPath(host, r.URL.Path)
+```
+
+Leave the call at `edge.go:1455` alone — that is the routability check the doc comment above `lookupUpstream` describes.
+
+Do not move this call within `ServeHTTP`. Its position after `acceptHop` is load-bearing: a backhauled request must resolve its prefix exactly once, on the box that ultimately serves it.
+
+- [ ] **Step 7: Run the tests to verify they pass**
+
+Run: `go test ./internal/edge/ -count=1`
+Expected: PASS, including every existing edge test — especially the alias tests, which prove the alias fold still behaves.
+
+- [ ] **Step 8: Verify the whole build is clean**
+
+Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1`
+Expected: all clean.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add internal/edge/edge.go internal/edge/path_test.go
+git commit -m "edge: resolve a leading path segment for hosts that carry a prefix map
+
+Host resolution is unchanged and stays the primitive: the routability check
+in the cert path asks only whether this box serves a hostname, and must not
+gain a path. Request dispatch gets a path-aware wrapper that reads one
+snapshot of the map for both lookups.
+
+Matching is segment-wise equality, so /paulcarson is never dragged across a
+migration by /paulca, and the prefix is not stripped, so the flipped and
+unflipped URL spaces stay identical. Every miss — unlisted prefix, dangling
+target, bare / — falls through to the hostname's own route.
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: The write door and the read surfaces
+
+**Files:**
+- Modify: `internal/console/apidomains.go` (new handler, alongside the capabilities handler ~line 482)
+- Modify: `internal/console/authcli.go` (route registration, ~line 624)
+- Modify: `routes_cli.go` (path rows in `carlos routes`)
+- Test: `internal/console/apidomainpaths_test.go` (create)
+
+**Interfaces:**
+- Consumes: `domains.SetClaimPaths`, `domains.ErrTooManyPaths`, `domains.ErrBadPathPrefix`, `domains.MaxPathsPerClaim` (Task 2).
+- Produces: `PUT /api/cli/apps/{account}/{app}/domains/{host}/paths`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/console/apidomainpaths_test.go`. Read `internal/console/apidomains_test.go` first and reuse its server/auth harness verbatim — the exact helper names differ by file and must not be guessed.
+
+Cover, using that harness:
+
+1. **`TestAPIDomainPathsPublishes`** — `PUT` with body `{"paths":{"paulca":"go.tito.io"}}` returns 200, and `domains.GetClaim` shows the map.
+2. **`TestAPIDomainPathsReplaces`** — a second `PUT` with `{"paths":{"bigcorp":"go.tito.io"}}` leaves `bigcorp` only; `paulca` is gone.
+3. **`TestAPIDomainPathsEmptyBodyClears`** — `{"paths":{}}` clears the map and returns 200.
+4. **`TestAPIDomainPathsRejectsOtherAccount`** — a principal scoped to another account gets the family's byte-identical 404, not a 403.
+5. **`TestAPIDomainPathsRejectsOversize`** — a map above `domains.MaxPathsPerClaim` returns 422.
+6. **`TestAPIDomainPathsRejectsBadPrefix`** — `{"paths":{"/paulca":"go.tito.io"}}` returns 422.
+
+Each asserts the status code and, where it applies, reads the claim back to prove the effect.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `go test ./internal/console/ -run TestAPIDomainPaths -count=1`
+Expected: FAIL — 404 from an unregistered route.
+
+- [ ] **Step 3: Add the handler**
+
+In `internal/console/apidomains.go`, following the shape of the capabilities handler:
+
+```go
+// 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.
+func (s *Server) handleAPIDomainPaths(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ acct, app, ok := s.apiAppScope(w, r) // the family's existing scope helper
+ if !ok {
+ return
+ }
+ host := r.PathValue("host")
+
+ var body struct {
+ Paths map[string]string `json:"paths"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<20)).Decode(&body); err != nil {
+ jsonError(w, http.StatusBadRequest, "could not parse the request body as JSON")
+ return
+ }
+
+ changed, err := domains.SetClaimPaths(ctx, s.cfg.Releases, host, acct.Sqid, app, body.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.ErrHostUnavailable) {
+ // The family's byte-identical 404 discipline: a caller who does not
+ // hold this claim learns nothing about whether it exists.
+ 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
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "host": host,
+ "paths": body.Paths,
+ "changed": changed,
+ })
+}
+```
+
+Match `apiAppScope` and `writeJSON` to whatever the neighbouring handlers in this file actually call — read them and use the same names. The 8MB body cap is sized for the `MaxPathsPerClaim` ceiling with headroom.
+
+- [ ] **Step 4: Register the route**
+
+In `internal/console/authcli.go`, beside the other domain routes:
+
+```go
+ s.cliRoute("PUT /api/cli/apps/{account}/{app}/domains/{host}/paths", svcrole.Allow(svcrole.Publish), s.handleAPIDomainPaths)
+```
+
+`Publish` only, not `Instance`: publishing a prefix map is a deployment-shaped decision about where an account's traffic goes, and an instance-scoped token has no business making it.
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `go test ./internal/console/ -count=1`
+Expected: PASS.
+
+- [ ] **Step 6: Show path routes in `carlos routes`**
+
+In `routes_cli.go`, after each host's row, print its prefixes indented — read the file's existing table-building code and follow it exactly:
+
+```go
+ // Path routes under their hostname: the flipped set has to be visible
+ // from the command operators already use, or a migration is invisible
+ // until something breaks.
+ for _, p := range pathsFor(rt.Host) {
+ fmt.Fprintf(tw, " /%s\t\t-> %s\t\t\t\t\n", p.Prefix, p.Target)
+ }
+```
+
+Source the rows from the same API response the command already reads; extend that response to carry them if it does not. Read-only — no write verb (see §8 of the spec).
+
+- [ ] **Step 7: Run the full suite**
+
+Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1`
+Expected: all clean.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add internal/console/apidomains.go internal/console/authcli.go internal/console/apidomainpaths_test.go routes_cli.go
+git commit -m "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>"
+```
+
+---
+
+### Task 6: End-to-end proof
+
+**Files:**
+- Test: `internal/edge/path_e2e_test.go` (create)
+
+**Interfaces:**
+- Consumes: everything from Tasks 1-4.
+- Produces: nothing. This is the deliverable that says the machinery works.
+
+This is the acceptance bar from spec §10. With `ti.to` not moving, nothing else proves the pieces compose — each earlier task tested its own layer against a hand-written input.
+
+- [ ] **Step 1: Write the test**
+
+Create `internal/edge/path_e2e_test.go`:
+
+```go
+package edge
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// TestPathFlipEndToEnd walks the whole chain a real flip takes — claim
+// published, reconcile pass, fingerprint moves, edge reloads, traffic changes
+// — and then walks it backwards. Each earlier task proved one layer against a
+// hand-written input; this is the only test that proves they compose.
+//
+// The registry write and the edge reload are driven directly rather than
+// waited on: the 2s tick is a latency bound, not a correctness property, and
+// polling it would make this test slow and flaky. What must be true is that
+// the fingerprint MOVES, which is asserted rather than assumed.
+func TestPathFlipEndToEnd(t *testing.T) {
+ ctx := context.Background()
+ classic, _ := hostEchoBackend(t, "classic")
+ golive, _ := hostEchoBackend(t, "go")
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "ti.to", Kind: "service", Account: "tito", App: "classic",
+ Addr: classic.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "go.tito.io", Kind: "service", Account: "tito", App: "go",
+ Addr: golive.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ store := newTestStore(t) // the in-memory release.Store the domains tests use
+ if _, _, err := domains.AttachClaim(ctx, store, domains.Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ defer srv.Close()
+
+ // Before the flip: everything is classic.
+ if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "classic" {
+ t.Fatalf("before the flip: body = %q, want classic", body)
+ }
+
+ fpBefore, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // The flip: Tito publishes its map, the pass folds it, the edge reloads.
+ if _, err := domains.SetClaimPaths(ctx, store, "ti.to", "tito", "classic",
+ map[string]string{"paulca": "go.tito.io"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := domains.ReconcileAliases(ctx, store, reg, time.Now, nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ fpAfter, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fpBefore == fpAfter {
+ t.Fatal("fingerprint did not move across a flip — the edge's watcher would never reload")
+ }
+ if err := e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+
+ // After the flip: that account is on Go, its neighbours are not.
+ if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "go" {
+ t.Fatalf("after the flip: body = %q, want go", body)
+ }
+ if _, body := getHostPath(t, srv.URL, "ti.to", "/someoneelse/event"); body != "classic" {
+ t.Fatalf("after the flip: a neighbouring account moved too, body = %q", body)
+ }
+ if _, body := getHostPath(t, srv.URL, "ti.to", "/paulcarson"); body != "classic" {
+ t.Fatalf("after the flip: a longer account name was dragged along, body = %q", body)
+ }
+
+ // The rollback: republish without that account.
+ if _, err := domains.SetClaimPaths(ctx, store, "ti.to", "tito", "classic", nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := domains.ReconcileAliases(ctx, store, reg, time.Now, nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "classic" {
+ t.Fatalf("after the rollback: body = %q, want classic", body)
+ }
+ rows, err := reg.ListPaths()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) != 0 {
+ t.Fatalf("after the rollback: %d path rows remain, want 0", len(rows))
+ }
+}
+```
+
+If `internal/edge` cannot import `internal/domains` without a cycle, put this test in a new `internal/pathrouting/e2e_test.go` package that imports both. Check with `go build ./...` before assuming either way.
+
+- [ ] **Step 2: Run it**
+
+Run: `go test ./internal/edge/ -run TestPathFlipEndToEnd -count=1 -v`
+Expected: PASS.
+
+- [ ] **Step 3: Run the full suite**
+
+Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1`
+Expected: all clean.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/edge/path_e2e_test.go
+git commit -m "test: end-to-end path flip and rollback
+
+Walks claim -> reconcile -> fingerprint -> reload -> traffic, then walks it
+back. With ti.to not moving behind the edge, this is the only thing that
+proves the four layers compose rather than each working against a
+hand-written input.
+
+Asserts the fingerprint actually moves: a flip that reconciles correctly and
+never reaches the live map is the failure this feature is most exposed to.
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+- [ ] **Step 5: Open the PR**
+
+```bash
+git push -u origin HEAD
+```
+
+Then open a PR titled **"Path-based routing: one hostname, many upstreams"** whose body summarises the six tasks, links the spec, and carries the spec's §11 live checks as an explicit "not done, not claimed" list.
+
+Note: at time of writing this checkout has only `origin` → amadan.net, with no `amadan` remote and no GitHub remote configured, so the PR may need a remote added first. Per AGENTS.md, CI runs on a push to `amadan` — a branch pushed only to `origin` has been gated by nothing.
+
+---
+
+## Self-Review
+
+**Spec coverage**
+
+| Spec section | Task |
+|---|---|
+| §2 prefix selects a route; bare segments | 1 (table), 4 (lookup) |
+| §3 `Paths` claim field, wholesale publish, cap | 2 |
+| §4 `route_paths`, pass-owned, tenancy gates, `Slug` untouched | 1, 3 |
+| §5 `Fingerprint()` section + a test that the digest moves | 1 (unit), 6 (e2e) |
+| §6 `lookupUpstream(host, path)`, no stripping, segment bounds, three call sites | 4 |
+| §7 write door, `SetClaimPaths` single writer | 2, 5 |
+| §8 read-only surfaces | 5 |
+| §9 exclusions | Nothing implements them, by construction |
+| §10 rollout stages 1-5 | Tasks 1-6 |
+| §11 live checks | Carried into the PR body, not implemented |
+
+Spec §4's ruling that `Route.Slug` stays untouched is honoured by no task mentioning it.
+
+**Type consistency**
+
+`RoutePath{Host, Prefix, Target, Account, App}` is used identically in Tasks 1, 3 and 4. `ListPaths`/`UpsertPath`/`DeletePath` keep their Task 1 signatures throughout. `SetClaimPaths(ctx, s, host, account, app, paths)` is called with that argument order in Tasks 2, 5 and 6. `firstSegment`, `lookupIn` and `lookupUpstreamPath` are defined once in Task 4 and used only there. `MaxPathsPerClaim` is the exported spelling everywhere (Tasks 2, 3, 5) — the spec's prose says `maxPathsPerClaim`, and exported wins because Task 5's console error message needs it across a package boundary.
+
+**Known soft spots, called out rather than papered over**
+
+- Task 5's tests and handler depend on `internal/console` helper names (`apiAppScope`, `writeJSON`) that must be read from the file rather than trusted from this plan.
+- Task 6 may hit an import cycle; the fallback package is named in the step.
+- Task 5 Step 6 needs `carlos routes`' existing response shape extended; the plan says to follow the file rather than prescribing a diff, because that command's plumbing was not read while writing this.
diff --git a/docs/superpowers/specs/2026-09-02-path-based-routing-design.md b/docs/superpowers/specs/2026-09-02-path-based-routing-design.md
new file mode 100644
index 000000000..ea44193e0
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-02-path-based-routing-design.md
@@ -0,0 +1,597 @@
+# Path-based routing — one hostname, many upstreams
+
+**Date:** 2026-09-02 · **Status:** draft, awaiting review · **Requested by:** Paul
+
+**Builds on:** #151 (the global claim, `route_aliases`, the `domains` door),
+2026-08-27-managed-custom-domains-design.md (the claim → pass-owned-table
+idiom this spec extends), 2026-08-20-multi-region-ingress-design.md (the 2s
+fingerprint poll this spec must not bypass).
+
+## 0. How this spec was arrived at
+
+Four rounds of clarification with Paul. The scope rulings that shaped it:
+
+- **No Rust rewrite.** The router question that started this is dead. The
+ edge is not CPU-bound — a map lookup and an `io.Copy` per request — and the
+ 32k lines in `internal/edge` are certsync, backhaul, tunnels, hibernation
+ and purge, not proxy plumbing. A Rust edge would split one Go binary into
+ two build systems, two artifacts and two release channels to buy nothing
+ measurable. Path routing is a trie lookup layered on a map lookup; in Go
+ the question dissolves.
+- **Tito's platform owns the flip, not CARLOS.** Paul: *"i think the 'flip'
+ would happen on the tito platform side, no?"* Correct, and it is the
+ load-bearing ruling in this document. Tito knows which accounts are
+ migrated. CARLOS must not learn Tito's account model. CARLOS supplies a
+ routing primitive and a write door; Tito is the writer and the source of
+ truth.
+- **`ti.to` is NOT moving behind the CARLOS edge yet.** Paul: *"ti.to behind
+ CARLOS edge is not something I'm ready for, but I'm happy to deploy the
+ machinery."* So this spec builds and proves the machinery with no
+ production consumer on day one. §10 is how that is proven honestly rather
+ than by assertion.
+- **A route per prefix** as the routing model (Paul's explicit selection),
+ refined by §2 into a prefix that *selects* a route rather than defining one.
+
+---
+
+## 1. The two cases, and why only one needs code
+
+**Case 1 — Tito classic on `ti.to`, path-based.** Today `ti.to/paulca` is the
+account page, `ti.to/paulca/awesomeconf` the event, and
+`ti.to/paulca/awesomeconf/admin` the admin. The first path segment is the
+account, and it is the flip discriminator. Flipping one account must move
+that account's whole subtree to Tito Go, reversibly, without touching the
+other accounts. **This needs new code.**
+
+**Case 2 — Tito Pro on `*.tito.page`, host-based.** Pro customers each get a
+CNAME like `deutsche-filmakademie.tito.page`. Flipping one means pointing
+that hostname at Tito Go.
+
+Case 2 needs **no new routing code**. `lookupUpstream` (edge.go:866) tries an
+exact host match before the one-label wildcard fallback, and that ordering is
+a documented guarantee, not an accident:
+
+```go
+func (e *Edge) lookupUpstream(host string) (upstream, bool) {
+ m := e.current()
+ if up, ok := m[host]; ok { // exact
+ return up, true
+ }
+ if i := strings.IndexByte(host, '.'); i > 0 {
+ if up, ok := m["*"+host[i:]]; ok { // one-label wildcard
+ return up, true
+ }
+ }
+ return upstream{}, false
+}
+```
+
+So a catch-all `*.tito.page` row pointing at classic, plus an exact
+`deutsche-filmakademie.tito.page` row pointing at Go, gives a per-customer
+flip today. Rollback is deleting the exact row. What is *not* yet proven is
+the operational path to that state — see §11.
+
+The rest of this spec is case 1.
+
+---
+
+## 2. The model: a prefix selects a route, it does not define one
+
+The obvious design gives each prefix its own upstream, channel and
+environment. That is wrong: it duplicates a route's entire configuration per
+account, and Tito already has both backends as real routes.
+
+Instead, mirror `route_aliases`, which already works exactly this way — an
+alias is a *pointer* to a primary host, and the primary supplies everything
+else. A path route is that same pointer with a prefix attached:
+
+```
+route_paths(host, prefix) -> target_host
+
+ ti.to paulca -> go.tito.io
+ ti.to bigcorp -> go.tito.io
+ ti.to (default) -> ti.to's own route -> classic
+```
+
+A prefix is stored as a **bare segment, no leading slash** — `paulca`, not
+`/paulca` — on the wire, in the table and in the lookup, so there is exactly
+one spelling and no normalisation step to get wrong. Surfaces may render it
+with a slash for readability; nothing else adds one.
+
+That is a statement about the **stored prefix**, and only about it. The
+**request path** is attacker-shaped and arrives in whatever spelling a client
+chooses, so the matcher does normalise it — see §6, which describes what it
+normalises and why one canonical form is not enough. The two are separate
+problems: one spelling on the write side, several on the read side.
+
+`go.tito.io` is already a route carrying its own channel, environment, health
+probe, hibernation policy and usage metering. The row that points at it
+carries no upstream, no channel and no environment of its own. Nothing is
+duplicated, and every existing subsystem keeps working because the thing
+being selected is an ordinary route.
+
+This also buys something that was not asked for and costs nothing:
+**canarying a subset of accounts.** Point most flipped accounts at the stable
+Go route and a handful at a second Go route bound to a beta channel. It falls
+out of the model; there is no special case for it.
+
+**Migration progress is the row count.** `route_paths` holds only flipped
+accounts. Rollback for one account is one row removed. When everyone is
+across, repoint `ti.to`'s own route at Go and drop the rows entirely.
+
+---
+
+## 3. Wire: path routes are a third claim capability
+
+The control-plane object already exists. A claim (`internal/domains/claims.go:34`)
+lives at `domains/<host>.json` — **one object per hostname**, which is exactly
+the granularity a per-host prefix map wants. It already carries `Account` and
+`App` for tenancy, is written under a CAS etag, and is folded into the
+registry on the fast-converge leg every ~2s.
+
+It is also explicitly built to grow:
+
+> *"any future claim field must be added here in the same change that first
+> writes it"* — claims.go:50-55
+
+So the wire change is one additive field:
+
+```go
+// Paths maps a leading URL path segment to the route that serves it,
+// for a host whose traffic is split by path rather than by hostname.
+// Keys are single segments WITHOUT slashes ("paulca"); values are
+// hostnames of routes in the same account. Absent or empty means this
+// host routes by hostname alone — the behaviour of every host today.
+Paths map[string]string `json:"paths,omitempty"`
+```
+
+**Tito publishes this wholesale, regenerated from its own database.** Not
+deltas. A lost delta desyncs the two systems silently and nobody finds out
+until a customer reports a wrong page; a wholesale republish is a full
+repair, and republishing is always safe. One `PUT` per flip regardless of how
+many accounts are in the map.
+
+Deliberate departures, called out because they cut against neighbouring
+idioms:
+
+- **Replace-on-write, not create-only.** `provision.Record` is create-only
+ (409 on race, never a silent overwrite) because an instance record is a
+ one-shot fact. A migration toggle is neither one-shot nor one-way. The CAS
+ etag `getClaimETag` already carries is what keeps concurrent writes honest.
+- **A map field, not one object per flip.** One object per account would mean
+ 10k+ bucket objects for a state that is one table in Tito's database.
+
+**Cap.** Mirror `maxWildcardDomains` (aliases.go:25) with `maxPathsPerClaim =
+10000` — deterministic drops in sorted key order, logged, never silent.
+Unlike the wildcard cap this does not defend an external budget (there is no
+ACME equivalent here); it bounds object size and the per-box fold. At ~40
+bytes an entry, 10k entries is ~400KB re-Put per flip and refolded every 2s
+per box, which is comfortable. Sharding a host's map across objects is out of
+scope and should not be built until a real host approaches the cap.
+
+---
+
+## 4. Registry: `route_paths`, wholly pass-owned
+
+```sql
+CREATE TABLE IF NOT EXISTS route_paths (
+ host TEXT NOT NULL,
+ prefix TEXT NOT NULL,
+ target TEXT NOT NULL,
+ account TEXT NOT NULL,
+ app TEXT NOT NULL,
+ PRIMARY KEY (host, prefix)
+);
+```
+
+`host TEXT PRIMARY KEY` on `routes` is what forced this to be a separate
+table: a hostname is one row there, and it must stay one row, because that
+row is what owns the cert, the home box, the DNS state and the aliases.
+
+Derivation extends `ReconcileAliases` (`internal/domains/aliases.go:48`),
+which already folds claims into `route_aliases` and `wildcard_domains` on the
+same pass, and already states the property this table needs:
+
+> *"Both tables are wholly pass-owned: every row traces to a claim, so
+> removal needs no ownership flag."*
+
+Same rule here. Every `route_paths` row traces to a claim's `Paths` entry;
+rows with no claim behind them are deleted every pass. A republished map with
+an account removed un-flips that account on the next tick, with no delete
+verb anywhere.
+
+Tenancy gates, mirroring the existing alias gates in the same loop:
+
+- The claim's host must resolve to a local route whose account and app match
+ the claim — the check already there for aliases.
+- **The target must belong to the same account.** A prefix must never be able
+ to point another tenant's traffic at your backend. This is the one genuinely
+ new authorization rule in the spec and the one most worth reviewing.
+- A target that is not a local ready route contributes no row, and logs. The
+ prefix then falls through to the host's default — a missing Go route serves
+ classic, which is the correct failure direction for a migration.
+
+`Route.Slug` (registry.go:36 — *"app-account short name usable on a root
+domain. Empty = subdomain-only"*) is carried by the registry and by
+`provision.Record` and read by nothing. It is an unwired hook for root-domain
+path routing that someone anticipated. This spec does **not** use it:
+`route_paths` is Tito-published desired state, while `Slug` is a property a
+route asserts about itself, and conflating them would give two writers of one
+routing decision. Leave it alone; if it is still dead after this ships, delete
+it in a separate change.
+
+---
+
+## 5. `Fingerprint()` must fold in `route_paths`
+
+`Fingerprint()` (registry.go:1676) is the 2s content hash the edge polls, and
+it hashes `routes`, `fleets`, `fleet_boxes`, `route_aliases` and
+`wildcard_domains` — each under its own section marker so a value can never
+migrate between sections and cancel a change out.
+
+Add a `route_paths` section on the same pattern:
+
+```go
+fmt.Fprint(h, "\x1droute_paths\x1e")
+// SELECT host, prefix, target, account, app FROM route_paths ORDER BY host, prefix
+```
+
+All five columns hash; there is no cadence-stamped column here to exclude, so
+this table needs none of the `dns_status`/`cert_expires_at` care that
+`route_aliases` does.
+
+**This is the easiest thing in the spec to omit and the worst to omit.** The
+failure mode is silent: flips write correctly, reconcile correctly, land in
+the table correctly, and never reach the live map — the edge keeps serving the
+old answer until something unrelated moves the digest. The function's own
+header already names this class of bug as the one to engineer against:
+
+> *"the failure mode worth engineering against is the false NEGATIVE."*
+
+A test that asserts the digest moves when a `route_paths` row changes is a
+required deliverable, not a nice-to-have.
+
+---
+
+## 6. The edge: `lookupUpstream(host, path)`
+
+Host resolution is unchanged. Only when the resolved host has a prefix map
+does a second lookup happen:
+
+1. Resolve `host` exactly as today (exact, then one-label wildcard).
+2. If that host has no prefix map — every host on the platform today — return
+ the route. One nil map check, no allocation, no behaviour change.
+3. Otherwise take the first path segment — in **both** its raw and its
+ normalised spelling, see below — and look it up. A hit returns the
+ target's upstream; a miss returns the host's own route.
+
+**No prefix stripping.** `ti.to/paulca/awesomeconf` arrives at Tito Go as
+`/paulca/awesomeconf`. Go already knows the account is the first segment
+because that is how the URLs are shaped; stripping would make the flipped and
+unflipped URL spaces differ, which is exactly what a seamless migration must
+not do. This holds for the odd shapes too: matching normalises, **proxying
+never does**, so the upstream always receives the request's original bytes.
+
+**Segment-boundary matching, on both spellings of the first segment.** Because
+the stored prefix is a bare segment (§2), the match is a plain equality test
+against the request's first segment — never a `strings.HasPrefix` on the raw
+path. So the prefix `paulca` serves `/paulca` and `/paulca/awesomeconf/admin`,
+and never `/paulcarson`. A request for `/` matches no prefix and falls through
+to the host's own route.
+
+"The request's first segment" is two strings, not one, and the map is
+consulted for both: the **raw** split of the path as it arrived, and the
+**normalised** one (`path.Clean` first, so leading empty segments and dot
+segments resolve). Either may hit; when both hit and they name different
+prefixes, the normalised one wins.
+
+Both are needed because they fail in opposite directions, and the invariant
+being protected is one-way — **the app an account was flipped away from must
+never answer for that account.** Serving a not-yet-flipped account from the
+new app is the benign direction, at worst a 404 while the migration runs.
+
+- **Raw alone** misses `//paulca/foo`. Most backends, Rails included, collapse
+ that to `/paulca/foo` and serve it, so the request would fall through to the
+ legacy app — which then answers for a flipped account. One character,
+ silent.
+- **Normalised alone** misses `/paulca/../bob`, which resolves to `bob`. A
+ backend that does not collapse `..`, or that routes on the first segment
+ with a glob like `/:account/*rest`, would then serve paulca's page from the
+ legacy app. The same bypass from the other side.
+
+Taking a hit from either closes both. The cost is that `/paulca/../bob`
+reaches the new app carrying `/paulca/../bob`, which a normalising target
+resolves to `/bob` and 404s — an account it was not given. Benign, and
+deliberate.
+
+**Residue**, recorded so it is not re-derived later. None of it crosses the
+invariant above:
+
+- `/%2e%2e/bob` — `r.URL.Path` is already percent-decoded, so this normalises
+ to `/bob` and matches `bob`. The upstream receives the raw `/%2e%2e/bob` and
+ will most likely 404 it. Harmless.
+- `/paulca%2ffoo` — decodes to `/paulca/foo`, so both spellings match `paulca`
+ and the request reaches the new app, while a strict backend treats the
+ upstream path as the single segment `paulca/foo`. A mis-flip toward the new
+ app: the safe direction.
+- A prefix literally named `.` or `..` cannot exist — `validPathPrefix`
+ refuses both — so the raw spelling of `/../bob` can never match anything.
+
+**The scope of the guarantee**, stated so it is not over-read. The two
+spellings model exactly two normalisations — percent-decoding and dot-segment
+removal — and the invariant holds across every combination of a backend doing
+or not doing those two. It is *not* a claim that no other normalisation
+exists. A backend that folds case (`/PAULCA/foo`), strips servlet-style path
+parameters (`/paulca;jsessionid=x/foo`), accepts backslash separators, strips
+trailing dots, or double-decodes could resolve a path neither spelling matches
+into a flipped account's subtree.
+
+Rails does none of these — Rack keeps `;` in `PATH_INFO`, and routing is
+case-sensitive — so Tito is not exposed, which is why this is recorded rather
+than defended against. A future consumer with different normalisation
+behaviour needs this reasoning redone, not inherited.
+
+**Three call sites, and they do not all want a path** (this is the subtle part):
+
+- `edge.go:930` — the ServeHTTP dispatch. Takes the path.
+- `edge.go:1455` — a `routable` check asking only *is this hostname served
+ here at all*. Must stay host-only; giving it a path would make a hostname's
+ routability depend on which URL happened to ask.
+- `edge.go:866` — the definition.
+
+So the host-only form stays as the primitive and the path-aware form wraps
+it, rather than the path-aware form replacing it everywhere.
+
+`ServeHTTP`'s ordering (edge.go:879) is load-bearing and documented: steering
+probe → reserved tunnel hostname / store door → `acceptHop` → lookup → miss
+handling → usage metering wrapping `w` exactly once. Path resolution goes
+**inside** the existing lookup step. It must not move anything above it —
+particularly not `acceptHop`, or a backhauled request would resolve its path
+twice, once on each box.
+
+Untouched: `homemap`, `backhaul`, `certsync`, `certwarm`, `wildcard`, the
+activator, the cache. That is the payoff of keeping the hostname as the unit
+of placement: `ti.to` is one hostname with one home box and one cert whatever
+its prefix map says, so none of that machinery has to learn about paths.
+`homemap`'s lookup is documented as mirroring `lookupUpstream` *"rule for
+rule"* — this spec keeps that true, because the rule it mirrors is the
+host-only one.
+
+**But that is a statement about the HOSTNAME, not a promise that path routing
+works across boxes. It does not.** Both halves of the resolution — the
+reconcile pass's `byHost` and the edge's live map — are built from *this
+box's* routes, and backhaul fires only on a **host** miss, never for a path
+target. So the constraint is:
+
+> **A prefix's target route must be homed on the same box as the hostname's
+> own route.** A target on another box derives no `route_paths` row anywhere,
+> on any box, and every request keeps reaching the hostname's own route.
+
+That failure direction is safe (traffic serves the origin, never a 404) but it
+is invisible in the traffic, so the reconcile pass logs it: one aggregated
+line per host per pass naming the count and the first few unresolved targets,
+distinguishing "the target does not exist" from "the target is homed
+elsewhere" because the remedies differ. Making cross-box path targets actually
+work needs a backhaul equivalent for path targets, which is a design change,
+not a fix. See §11.
+
+**Attribution: a path hit returns the TARGET's upstream entry wholesale.**
+Everything downstream that reads the resolved entry reads the target's route,
+not the requested hostname's. Four consequences, three of them correct by
+design and one a known gap:
+
+- **Usage metering** bills the target's route, account and app. Correct — the
+ target's backend burned the bytes. Pinned by a test so a later "fix" in the
+ other direction is caught.
+- **The `X-Carlos-Version` header** reports the target's release. Correct — it
+ names the code that produced the response.
+- **Activator keying and hibernation** are the target's, so a flipped prefix
+ wakes the target instance. Correct — that is the process that has to be
+ awake.
+- **Purge tenancy** is the target's, while the response **cache keys on the
+ requested hostname**. That mismatch is a real gap; §11 records it and its
+ bound.
+
+---
+
+## 7. The write door
+
+An earlier revision of this design claimed a route write API was net-new
+work, because `internal/console/apiroutes.go` is read-only. That was looking
+in the wrong place. The claim API already exists, with service-role scoping
+(`internal/console/authcli.go:618-624`):
+
+```
+GET /api/cli/apps/{account}/{app}/domains
+POST /api/cli/apps/{account}/{app}/domains
+GET /api/cli/apps/{account}/{app}/domains/{host}
+DELETE /api/cli/apps/{account}/{app}/domains/{host}
+```
+
+Tito's door is a sibling:
+
+```
+PUT /api/cli/apps/{account}/{app}/domains/{host}/paths
+```
+
+**Wire shape.** The body is the whole map, WRAPPED in a `paths` field — not a
+bare map:
+
+```json
+{ "paths": { "paulca": "go.tito.io", "bigcorp": "go.tito.io" } }
+```
+
+`{"paths": {}}` is how a map is cleared. A bare map, or a body whose `paths`
+field is absent or misspelled, answers **400**. The wrapper is what makes that
+possible: `paths` is decoded into a POINTER, so "absent" and "empty" are
+distinguishable, and a typo in the field name becomes a refusal instead of a
+silent wholesale clear of every flip on the hostname. (An earlier revision of
+this spec said "body is the whole map", which would fail on the first call —
+corrected here.)
+
+It reuses the existing auth, account scoping and service roles wholesale, and
+its handler is the shape of the capability handler at apidomains.go:482: read
+claim under etag, replace one field, CAS write.
+
+**Roles: `Publish` AND `Instance`.** The first implementation made this
+`Publish` only, reasoning that deciding whose traffic leaves the legacy app is
+a deployment-shaped act. Review overturned that on internal coherence: the
+paths *read* (`GET .../domains/{host}`) is gated `Publish+Instance`, so
+`Publish`-only left an Instance credential able to read a hostname's published
+map and not write it — and `apidomains.go`'s own file header names Tito's
+router, calling this family with its `CARLOS_INSTANCE_TOKEN`, as the caller
+the door exists for. Widening crosses no tenancy boundary: the door is
+owner-gated on account and app, so an Instance token can only ever publish its
+own app's map, and the reconcile pass re-checks every target against the same
+account regardless. Recorded here so the scoping is a visible choice rather
+than an accident.
+
+**`refuseServiceControlPlane`.** A SERVICE credential of any role is refused
+outright when the app is the deployment's own control plane; a person is not.
+A prefix map on the console's own hostname would hand a slice of the console's
+URL space (`/admin`, `/api`) to whatever else that account runs, and that is
+not a decision a CI or router credential should be able to take. 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 body.
+
+Following `SetClaimCapabilities`' precedent (claims.go:279, *"the ONLY writer
+of a claim's Wildcard/Catchall fields"*), a single `SetClaimPaths` is the only
+writer of `Paths`, so `AttachClaim`'s converge can neither invent nor strip
+the map.
+
+**Flip latency, end to end:** `PUT` → bucket → **serial bump** →
+fast-converge tick (`fastTickEvery = 2s` plus jitter, agent.go:1053/1504) →
+registry write → 2s fingerprint poll → live map. **Under about five
+seconds**, which is inside what a Tito admin toggle can plausibly wait on.
+
+The serial bump is not incidental, and this document's first revision
+omitted it — the omission was caught during implementation. Without it the
+write lands in the bucket correctly and then waits for the *hourly* pass,
+turning a five-second flip into an hour-long one. Attach and detach already
+bump for exactly this reason, so the door must do what its siblings do:
+write, then audit, then bump, all inside the `changed` branch so a no-op
+republish costs neither an audit row nor a fleet-wide reload.
+
+The audit row matters independently of latency. A prefix map decides where
+an account's traffic goes, so "who moved this hostname's traffic, and when"
+has to be answerable afterwards. The row records actor, account, app, host
+and the prefix count — deliberately not the targets themselves, since the
+map may hold ten thousand entries.
+
+---
+
+## 8. Surfaces
+
+- `carlos domains paths <host>` — list a host's prefix map.
+- `carlos routes` gains path rows, indented under their host, so the flipped
+ set is visible from the same command operators already use.
+- Both read-only. Tito is the writer; a CLI write verb would create a second
+ writer of state whose source of truth is Tito's database, and the two would
+ drift the first time anyone used it.
+
+---
+
+## 9. What this does NOT do
+
+- **No regex, globs, or multi-segment prefixes.** First segment, exact match.
+ Tito's URL shape is `/<account>/...` and nothing here should outgrow that
+ before there is a second consumer to generalise against.
+- **No reserved-prefix list.** `ti.to/login` and `ti.to/assets` must not be
+ shadowed by an account slug — but Tito already guarantees that, because
+ those names cannot be registered as accounts. Duplicating the list in CARLOS
+ gives two places to update and one to forget. If this proves wrong, it is a
+ small additive change to the reconcile gate.
+- **No per-prefix channel or environment.** Point the prefix at a route that
+ has them (§2).
+- **No path-aware certs, DNS, or placement.** A hostname remains the unit of
+ all three.
+- **No `ti.to` migration.** Explicitly out of scope per Paul's ruling.
+
+---
+
+## 10. Rollout, and how this is proven without `ti.to`
+
+The honest risk of building machinery with no consumer is that it ships
+looking finished and is wrong in a way nobody discovers for months. So the
+acceptance bar is an end-to-end flip on a real hostname, not unit tests.
+
+1. **Table, wire field, fingerprint section.** Additive; zero rows on every
+ existing deployment; no behaviour change. The fingerprint test from §5
+ lands here.
+2. **Reconcile derivation + tenancy gates**, including a test that a target
+ in another account is refused.
+3. **`lookupUpstream(host, path)`** plus the three call sites, with the
+ `edge.go:1455` host-only form preserved.
+4. **The write door and `SetClaimPaths`.**
+5. **End-to-end proof on a scratch host.** Two trivial apps, `A` and `B`, on
+ one hostname: `scratch.example/b` reaches `B`, everything else reaches `A`,
+ `/bat` reaches `A` (boundary), removing the entry from a republished map
+ restores `A` within ~5s. This is the deliverable that says the machinery
+ works — everything before it is scaffolding.
+
+Stages 1-4 are independently landable PRs. Nothing is observable until 5.
+
+---
+
+## 11. Live checks owed, and known gaps
+
+None is a blocker for stages 1-5; all are blockers before Tito relies on
+any of it.
+
+- **Case 2, end to end.** Can `*.tito.page` be created through a supported
+ door? `provision.ValidHostShape` (provision.go:250) checks every label
+ against `labelRe`, which a literal `*` cannot pass — so wildcard rows arrive
+ by some other route (a `Wildcard`+`Catchall` claim, per aliases.go, is the
+ likely one). And the delegated wildcard cert path needs proving with a real
+ handshake. This is read-from-code, not observed. **Case 2 is the case Paul
+ can act on soonest, and it is the one with the least evidence behind it.**
+- **`ti.to` behind the CARLOS edge.** Out of scope here, but it is the
+ prerequisite for case 1 ever being used, and it is the largest single risk
+ in the whole migration. Worth its own spec when Paul is ready.
+
+- **The production converge loop actually folds a path claim on schedule.**
+ §10's end-to-end test drives `ReconcileAliases` and `Edge.Reload` directly,
+ which is the right call — the 2s tick is a latency bound, not a correctness
+ property, and polling it would make the fastest test in the plan slow and
+ flaky. But that means the test proves the layers compose *given that
+ something calls those two functions*. It does not prove that the real
+ converge loop (`internal/host/agent.go:1475` and `:1955`, the two
+ production call sites of `ReconcileAliases`) invokes them for a live path
+ claim on the real schedule.
+
+ The general fingerprint→reload mechanism is not unproven — `Edge.Watch` is
+ exercised against a live goroutine with bounded polling elsewhere in
+ `internal/edge`. What is unproven is specifically that a published `paths`
+ map traverses the real host agent's timing end to end. That is an
+ integration concern a package-level test cannot absorb, so it belongs here
+ rather than in the suite. **It is also the check that would falsify §7's
+ "under about five seconds" claim if that claim is wrong**, which makes it
+ the most load-bearing of the three items in this section.
+
+- **Cross-box path targets.** Both halves of the path resolution read this
+ box's routes only, and there is no backhaul equivalent for a path target
+ (§6). `ti.to` on one box and `go.tito.io` on another — the likely shape for
+ two apps as different as Tito classic and Tito Go — derives no rows and
+ routes nothing, forever. The reconcile pass now says so in the log, but
+ nothing enforces the constraint at the write door and nothing has ever been
+ observed. Before any real flip: confirm the hostname's route and every
+ target route are homed on the same box, and decide whether making it work
+ across boxes is wanted (a design change, not a fix).
+
+**Known gap: a path-routed app cannot purge its own cache.** The response
+cache keys on the REQUESTED hostname (`ti.to`), while `purgeModifier` is built
+from the resolved route — the TARGET's (`go.tito.io`). So a `Carlos-Purge`
+emitted by the Go app purges under `go.tito.io` and drops nothing; the entry
+lives under `ti.to`. The documented `Carlos-Purge-Host: ti.to` escape hatch
+does not help either: `resolvePurgeHost` requires the override to name a host
+in the same app, and path routing exists precisely to cross an app boundary.
+Aliases never hit this, because an alias maps to the primary's own route and
+the app comparison passes trivially.
+
+The bound: a path-routed response can go stale for at most the cache's max
+TTL, and only for responses that opted into caching with a positive
+`max-age`. Uncached responses are unaffected. Deliberately NOT fixed here —
+changing the purge tenancy model is unreviewed territory this feature did not
+design. Documented on `lookupUpstreamPath` and recorded here so a consumer
+knows before it relies on purge.
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
new file mode 100644
index 000000000..65ca4d4ec
--- /dev/null
+++ b/internal/console/apidomainpaths_test.go
@@ -0,0 +1,348 @@
+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"
+ "strings"
+ "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)
+ }
+}
+
+// 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
+}
+
+// TestAPIDomainPathsReachesPublishAndInstance is the door's role contract.
+//
+// Publish AND Instance, matching the READ of the same state beside it (GET
+// .../domains/{host} carries both). An earlier revision was Publish-only on
+// the reasoning that a flip is deployment-shaped; that left an instance
+// credential able to READ a hostname's published prefix map and not write
+// it, while apidomains.go's own header names titogo's router — calling this
+// family with its CARLOS_INSTANCE_TOKEN — as the caller the door exists for.
+// The widening crosses no tenancy boundary: the door is owner-gated on
+// account and app and refuses a control-plane app, so an instance token can
+// only publish its OWN app's map.
+//
+// Operate is the negative control: widening the door must widen it to a
+// named pair, not to every service role.
+func TestAPIDomainPathsReachesPublishAndInstance(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.StatusOK {
+ t.Fatalf("instance-role publish = %d %s, want 200 — the router that reads this map must be able to write it", 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)
+ }
+ op := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate)
+ if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), op, body); code != http.StatusForbidden {
+ t.Fatalf("operate-role publish = %d %s, want 403 — the door is two named roles, not every service role", code, out)
+ }
+}
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 6991285a0..35bcdd10d 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,149 @@ 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,
+ })
+}
+
+// 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
@@ -830,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/apiroutes.go b/internal/console/apiroutes.go
index 489397f96..4123bee93 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,48 @@ 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.
+ // make, not a nil slice: an empty answer must serialise as [] like
+ // `routes` beside it does, never as null. A client that has to handle
+ // both spellings of "none" for two keys in one object is a client that
+ // will handle one of them wrong.
+ paths := make([]apiRoutePath, 0)
+ 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.
+ // release.RouteAccount, matching the routes filter above rather
+ // than comparing the column raw. The warning there applies here
+ // word for word: a second, private notion of "whose row is this"
+ // is exactly how a listing ends up disagreeing with the
+ // reconciler about ownership. A raw compare fails CLOSED today
+ // (an unstamped account never equals a sqid, so the row is just
+ // hidden), but "fails closed" is not the rule this file states,
+ // and the two filters must resolve ownership identically or a
+ // route can appear with its own prefixes missing.
+ if p.App != app || release.RouteAccount(registry.Route{Account: 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..e2a9a6a33 100644
--- a/internal/console/apiroutes_test.go
+++ b/internal/console/apiroutes_test.go
@@ -9,6 +9,7 @@ package console
import (
"net/http"
+ "strings"
"testing"
"time"
@@ -207,3 +208,79 @@ func TestAPIRoutesAppTokenIsRefused(t *testing.T) {
t.Fatal("an app token reached the routes door — it must not")
}
}
+
+// TestAPIRoutesPathsIsAnEmptyArrayNotNull: no path rows must serialise as [],
+// the way `routes` beside it always does. A key that is sometimes null and
+// sometimes an array is a key a client eventually handles wrong, and every
+// deployment on the platform hits this branch until someone publishes a map.
+func TestAPIRoutesPathsIsAnEmptyArrayNotNull(t *testing.T) {
+ fx, token := routesFixture(t, "code-routes-nopaths")
+ status, body := getRaw(t, routesURL(fx, "titogo"), token)
+ if status != http.StatusOK {
+ t.Fatalf("GET routes = %d %s", status, body)
+ }
+ if !strings.Contains(body, `"paths":[]`) {
+ t.Fatalf("routes body = %s, want an empty paths ARRAY, not null", body)
+ }
+}
+
+// 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"},
+ // The rows that actually exercise the TENANCY compare. The two above
+ // sit on hostnames that are not in routeFor at all, so the host
+ // filter excludes them whatever their stamps say — delete the
+ // account/app compare and the test still passes. These two sit on
+ // connect.go.tito.io, which IS in the answer, and are stamped to
+ // another app and another account: only the tenancy compare keeps
+ // them out.
+ {Host: "connect.go.tito.io", Prefix: "otherapp", Target: "router-a.go.tito.io", Account: "bab", App: "kass"},
+ {Host: "connect.go.tito.io", Prefix: "otheracct", Target: "router-a.go.tito.io", 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"])
+ }
+ for _, r := range rows {
+ m, _ := r.(map[string]any)
+ if p := str(t, m, "prefix"); p == "otherapp" || p == "otheracct" {
+ t.Fatalf("paths carries %q — a row on a hostname in the answer but stamped to another app or account is another tenant's routing table", p)
+ }
+ }
+ // 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..232143bbb 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -622,6 +622,30 @@ 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` AND `Instance`, matching the READ of the same state on
+ // handleAPIDomainGet above. An earlier revision made this Publish-only on
+ // the reasoning that a flip is deployment-shaped; the objection that
+ // carried is internal coherence — as Publish-only, an Instance credential
+ // could read a hostname's published prefix map and not write it, and this
+ // file's own header names titogo's router, calling the domains family
+ // with its CARLOS_INSTANCE_TOKEN, as the caller this door exists for.
+ //
+ // Widening crosses no tenancy boundary. The door is already owner-gated
+ // on account and app (domainsGate) and guarded by
+ // refuseServiceControlPlane, so an Instance token can only ever publish
+ // its OWN app's map, naming targets the reconcile pass then re-checks
+ // against the same account (aliases.go). It is not in
+ // TestInstanceRoleMatchesTodaysAppTokenReach's legacy map, because
+ // nothing reached it before service principals existed — see
+ // exemptFromLegacyPin, where the deliberate grant is recorded. Design
+ // spec §7.
+ s.cliRoute("PUT /api/cli/apps/{account}/{app}/domains/{host}/paths", svcrole.Allow(svcrole.Publish, svcrole.Instance), 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/console/controlplane_test.go b/internal/console/controlplane_test.go
index 181587124..ad3780db6 100644
--- a/internal/console/controlplane_test.go
+++ b/internal/console/controlplane_test.go
@@ -1278,3 +1278,98 @@ 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. BOTH service
+// roles the route admits are exercised (Publish and Instance — see
+// TestAPIDomainPathsReachesPublishAndInstance), because this guard, not the
+// role table, is what keeps either of them off the console's own hostname.
+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 other role the door admits, refused identically: an app's router
+ // holding a CARLOS_INSTANCE_TOKEN reaches this handler (that is the
+ // widening in spec §7) and meets the same guard.
+ inst := serviceToken(t, fx.st, OpsAccountID, "router", "kass", svcrole.Instance)
+ instStatus, _, instOut := rawReq(t, http.MethodPut, u, inst, body)
+ if instStatus != status || instOut != out {
+ t.Fatalf("an INSTANCE publishing paths for the control plane = %d %s, want the same %d %s a PUBLISH gets",
+ instStatus, instOut, status, 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/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index f5fac7a98..2c3fb99a4 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -230,9 +230,22 @@ func TestNoRoleReachesAMintFleetOrPolicyDoor(t *testing.T) {
// credential that may already CREATE and DELETE instances (both in `want`)
// is not gaining authority by relocating one. TestAPIInstanceMoveVerbs
// pins what the doors themselves refuse.
+//
+// PUT .../domains/{host}/paths is the seventh, and on the same footing: path
+// routing did not exist before service principals, so listing it in `want`
+// would make the legacy map lie. The instance role reaches it DELIBERATELY,
+// to match the READ of the same state (GET .../domains/{host}, exempted
+// above) — an app's router that may read its own published prefix map with
+// its CARLOS_INSTANCE_TOKEN is the writer this door was built for, and a
+// credential that may already attach and detach that hostname is not gaining
+// authority by saying how its traffic splits. The door is owner-gated on
+// account and app and refuses a control-plane app outright, so an instance
+// token can only ever publish its OWN app's map.
+// TestAPIDomainPathsReachesPublishAndInstance pins the contract itself.
var exemptFromLegacyPin = map[string]bool{
"GET /api/cli/apps": true,
"GET /api/cli/apps/{account}/{app}/domains/{host}": true,
+ "PUT /api/cli/apps/{account}/{app}/domains/{host}/paths": true,
"GET /api/cli/apps/{account}/{app}/features": true,
"GET /api/cli/apps/{account}/{app}/regions": true,
"GET /api/cli/apps/{account}/{app}/instances/{host}/move": true,
diff --git a/internal/domains/aliases.go b/internal/domains/aliases.go
index 21530eb4c..e4d315418 100644
--- a/internal/domains/aliases.go
+++ b/internal/domains/aliases.go
@@ -1,17 +1,21 @@
// Alias reconcile pass — derives the registry's route_aliases table AND its
-// wildcard_domains table from the global claims recorded in the bucket
-// (#151). A claim contributes an ordinary alias row when it names a Route
-// (see the loop below); a Wildcard claim additionally contributes a
-// wildcard_domains row (a delegated-ACME wildcard-cert parent), and a
+// wildcard_domains and route_paths tables from the global claims recorded in
+// the bucket (#151). A claim contributes an ordinary alias row when it names
+// a Route (see the loop below); a Wildcard claim additionally contributes a
+// wildcard_domains row (a delegated-ACME wildcard-cert parent), a
// Wildcard+Catchall claim also contributes a "*.<host>" route_aliases row
-// pointing at the same primary. See claims.go for the claim shape and
-// registry.go's RouteAlias/WildcardDomain for the storage shapes; this file
-// is the seam that turns one into the other.
+// pointing at the same primary, and a claim's Paths map contributes one
+// route_paths row per prefix (path-based routing). See claims.go for the
+// claim shape and registry.go's RouteAlias/WildcardDomain/RoutePath for the
+// storage shapes; this file is the seam that turns one into the other.
package domains
import (
"context"
"log"
+ "slices"
+ "sort"
+ "strings"
"time"
"github.com/carlosframework/platform/internal/registry"
@@ -24,13 +28,14 @@ import (
// order); drops are logged, never silent.
const maxWildcardDomains = 32
-// ReconcileAliases derives route_aliases and wildcard_domains from the
-// global claim records. Both tables are wholly pass-owned: every row traces
-// to a claim, so removal needs no ownership flag. The authoritative tenancy
-// gate is HERE — the door's route check is best-effort (it cannot see the
-// registry); a claim only materializes when its primary route exists
-// locally AND its account/app match the claim. Routes always win a host
-// collision: an alias (plain or catch-all) must never shadow a real route.
+// ReconcileAliases derives route_aliases, wildcard_domains and route_paths
+// from the global claim records. All three tables are wholly pass-owned:
+// every row traces to a claim, so removal needs no ownership flag. The
+// authoritative tenancy gate is HERE — the door's route check is
+// best-effort (it cannot see the registry); a claim only materializes when
+// its primary route exists locally AND its account/app match the claim.
+// Routes always win a host collision: an alias (plain or catch-all) must
+// never shadow a real route.
//
// deploymentRoutes is every hostname a live box in this deployment publishes
// as a REAL route (edgesfile.DeploymentRoutes). It extends "routes always
@@ -64,6 +69,7 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist
}
desired := map[string]bool{}
desiredWild := map[string]registry.WildcardDomain{}
+ desiredPaths := map[string]registry.RoutePath{} // key: host + "\x00" + prefix
wildCount := 0
for _, c := range claims {
// Plain alias derivation (#164): a routeless (first-route) claim is
@@ -110,6 +116,122 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist
}
}
+ // Path derivation (prefix routing). A claim's Paths map splits its own
+ // hostname's traffic by leading URL segment; each entry names the
+ // hostname of the route that serves that segment.
+ //
+ // Gated on the claim's host being a LOCAL route whose tenancy matches
+ // the claim — the same per-box rule the alias half above follows, and
+ // for the same reason: the prefix map belongs to that route, so a box
+ // that does not serve the route derives nothing.
+ //
+ // The TARGET is resolved through the same local byHost, which makes
+ // co-location a hard constraint of the feature: a prefix's target
+ // route must be homed on the same box as the hostname's own route.
+ // There is no backhaul equivalent for a path target — backhaul fires
+ // on a host miss, and the host resolves fine here — so a target on
+ // another box derives no row anywhere at all. See the unresolved log
+ // below, and design spec §6.
+ //
+ // Ordered BEFORE the wildcard section on purpose: Paths is a third
+ // claim capability independent of Wildcard (claims.go's comment on
+ // the field), and the wildcard section below `continue`s for every
+ // non-Wildcard claim — placed after that, a Paths-only claim would
+ // never reach this block.
+ if len(c.Paths) > 0 {
+ hostRt, ok := byHost[c.Host]
+ if !ok {
+ // The hostname's own route is not on this box; another box
+ // derives these rows.
+ } else if hostRt.App != c.App || release.RouteAccount(hostRt) != c.Account {
+ log.Printf("domains: paths on %s refused: route belongs to %s/%s, claim says %s/%s",
+ c.Host, release.RouteAccount(hostRt), hostRt.App, c.Account, c.App)
+ } else {
+ // Sorted so the cap below truncates DETERMINISTICALLY: every
+ // box must derive the identical subset, or the same URL would
+ // resolve differently depending on which edge answered.
+ prefixes := make([]string, 0, len(c.Paths))
+ for p := range c.Paths {
+ prefixes = append(prefixes, p)
+ }
+ sort.Strings(prefixes)
+ if len(prefixes) > MaxPathsPerClaim {
+ log.Printf("domains: paths on %s: %d prefixes exceeds the cap of %d, dropping %d in sorted order",
+ c.Host, len(prefixes), MaxPathsPerClaim, len(prefixes)-MaxPathsPerClaim)
+ prefixes = prefixes[:MaxPathsPerClaim]
+ }
+ // Unresolved targets are counted and named ONCE per host per
+ // pass, not logged per prefix: this pass runs every ~2s on
+ // every box, and a 10k-entry map whose targets are all
+ // elsewhere would otherwise write 10k lines every two
+ // seconds. The count is the operator's signal; the first few
+ // names are enough to identify which map is wrong.
+ unresolved := 0
+ var unresolvedEg []string
+ for _, prefix := range prefixes {
+ target := c.Paths[prefix]
+ if !validPathPrefix(prefix) {
+ log.Printf("domains: path %s/%s refused: not a bare path segment", c.Host, prefix)
+ continue
+ }
+ tgtRt, ok := byHost[target]
+ // The authorization rule this feature adds: a prefix must
+ // never point another tenant's traffic at your backend.
+ // Account only — an account routing between its own apps
+ // (classic and Go ARE two apps) is the whole use case.
+ // Checked before readiness so a cross-tenant target is
+ // named as cross-tenant rather than swept into the
+ // unresolved count below.
+ if ok && release.RouteAccount(tgtRt) != c.Account {
+ log.Printf("domains: path %s/%s refused: target %s belongs to %s, claim says %s",
+ c.Host, prefix, target, release.RouteAccount(tgtRt), c.Account)
+ continue
+ }
+ if !ok || tgtRt.Status != "ready" {
+ // The target is not a local READY route, which has
+ // three distinct causes with three different
+ // remedies, and the log below cannot tell them apart
+ // from here:
+ //
+ // 1. The target route does not exist anywhere — a
+ // typo in the published map, or a route deleted
+ // out from under it. Remedy: fix the map, or
+ // re-create the route.
+ // 2. The target exists but is HOMED ON ANOTHER BOX.
+ // byHost holds this box's routes only, and there
+ // is no backhaul equivalent for a path target
+ // (backhaul fires on a HOST miss), so nothing
+ // derives the row anywhere. Remedy: co-locate —
+ // a prefix's target must be homed on the same box
+ // as the hostname's own route (design spec §6).
+ // 3. The target is local but not ready yet — a
+ // route mid-provision. Self-resolving; the row
+ // appears on the pass after it goes ready.
+ //
+ // No row either way, so the prefix falls through to
+ // the host's own route — the right failure direction
+ // for a migration: a missing destination serves the
+ // ORIGIN, never a 404. Silent traffic is the danger
+ // here, not broken traffic, which is why this is
+ // logged at all.
+ unresolved++
+ if len(unresolvedEg) < 3 && !slices.Contains(unresolvedEg, target) {
+ unresolvedEg = append(unresolvedEg, target)
+ }
+ continue
+ }
+ desiredPaths[c.Host+"\x00"+prefix] = registry.RoutePath{
+ Host: c.Host, Prefix: prefix, Target: target,
+ Account: c.Account, App: c.App,
+ }
+ }
+ if unresolved > 0 {
+ log.Printf("domains: paths on %s: %d prefix(es) name no local ready route (e.g. %s) — the target is missing, or it is homed on another box; a path target must be homed on the SAME box as %s. Those prefixes serve %s's own route.",
+ c.Host, unresolved, strings.Join(unresolvedEg, ", "), c.Host, c.Host)
+ }
+ }
+ }
+
// Wildcard/catch-all derivation. A claim with Catchall but not
// Wildcard cannot come from the write path (SetClaimCapabilities
// refuses it), but a hand-written or pre-migration claim could carry
@@ -220,5 +342,27 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist
}
}
}
+
+ // route_paths is wholly pass-owned, like route_aliases and
+ // wildcard_domains: every row traces to a claim's Paths entry, so a
+ // prefix removed from a republished map disappears here with no delete
+ // verb anywhere. That is exactly how a per-account rollback reaches the
+ // edge.
+ existingPaths, err := reg.ListPaths()
+ if err != nil {
+ return err
+ }
+ for _, p := range desiredPaths {
+ if err := reg.UpsertPath(p); err != nil {
+ log.Printf("domains: upsert path %s/%s: %v", p.Host, p.Prefix, err)
+ }
+ }
+ for _, p := range existingPaths {
+ if _, ok := desiredPaths[p.Host+"\x00"+p.Prefix]; !ok {
+ if err := reg.DeletePath(p.Host, p.Prefix); err != nil {
+ log.Printf("domains: remove path %s/%s: %v", p.Host, p.Prefix, err)
+ }
+ }
+ }
return nil
}
diff --git a/internal/domains/aliases_paths_test.go b/internal/domains/aliases_paths_test.go
new file mode 100644
index 000000000..db02d65cc
--- /dev/null
+++ b/internal/domains/aliases_paths_test.go
@@ -0,0 +1,269 @@
+package domains
+
+import (
+ "bytes"
+ "context"
+ "log"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// pathsByPrefix collapses ListPaths into a prefix -> target map for one host.
+func pathsByPrefix(t *testing.T, reg *registry.Registry, host string) map[string]string {
+ t.Helper()
+ rows, err := reg.ListPaths()
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := map[string]string{}
+ for _, p := range rows {
+ if p.Host == host {
+ out[p.Prefix] = p.Target
+ }
+ }
+ return out
+}
+
+// TestReconcileDerivesPathRows is the feature: a claim's Paths map becomes
+// route_paths rows.
+func TestReconcileDerivesPathRows(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t)
+ addRoute(t, reg, "ti.to", "tito", "classic")
+ addRoute(t, reg, "go.tito.io", "tito", "classic")
+ putClaim(t, s, Claim{
+ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
+ CreatedAt: "2026-09-03T00:00:00Z",
+ Paths: map[string]string{"paulca": "go.tito.io"},
+ })
+
+ if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if got := pathsByPrefix(t, reg, "ti.to"); got["paulca"] != "go.tito.io" {
+ t.Fatalf("route_paths = %v, want paulca -> go.tito.io", got)
+ }
+}
+
+// TestReconcileRemovesUndesiredPathRows is the rollback path and the
+// pass-ownership property: a prefix dropped from the published map disappears
+// from the table on the next pass, with no delete verb anywhere.
+func TestReconcileRemovesUndesiredPathRows(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t)
+ addRoute(t, reg, "ti.to", "tito", "classic")
+ addRoute(t, reg, "go.tito.io", "tito", "classic")
+ putClaim(t, s, Claim{
+ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
+ CreatedAt: "2026-09-03T00:00:00Z",
+ Paths: map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"},
+ })
+ if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
+ map[string]string{"paulca": "go.tito.io"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ got := pathsByPrefix(t, reg, "ti.to")
+ if _, still := got["bigcorp"]; still {
+ t.Fatalf("route_paths = %v, want bigcorp gone after it left the published map", got)
+ }
+ if got["paulca"] != "go.tito.io" {
+ t.Fatalf("route_paths = %v, want paulca still routed", got)
+ }
+}
+
+// TestReconcileRefusesCrossTenantTarget is the one genuinely new authorization
+// rule in this feature: a prefix must never point another tenant's traffic at
+// your backend.
+func TestReconcileRefusesCrossTenantTarget(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t)
+ addRoute(t, reg, "ti.to", "tito", "classic")
+ addRoute(t, reg, "victim.example", "someone-else", "their-app")
+ putClaim(t, s, Claim{
+ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
+ CreatedAt: "2026-09-03T00:00:00Z",
+ Paths: map[string]string{"paulca": "victim.example"},
+ })
+
+ if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
+ t.Fatalf("route_paths = %v, want no row — the target belongs to another account", got)
+ }
+}
+
+// TestReconcileSkipsUnknownTarget: a target that is not a local route yields
+// no row, so the prefix falls through to the host's own route. That is the
+// correct failure direction for a migration — a missing Go route serves
+// classic rather than 404ing.
+func TestReconcileSkipsUnknownTarget(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t)
+ addRoute(t, reg, "ti.to", "tito", "classic")
+ putClaim(t, s, Claim{
+ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
+ CreatedAt: "2026-09-03T00:00:00Z",
+ Paths: map[string]string{"paulca": "not-deployed-here.example"},
+ })
+
+ if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
+ t.Fatalf("route_paths = %v, want no row for a target that is not a local route", got)
+ }
+}
+
+// TestReconcileSkipsWhenClaimHostIsNotALocalRoute: the prefix map belongs to
+// the hostname's own route. If that route lives on another box, this box
+// derives nothing — the same per-box rule the alias half already follows.
+func TestReconcileSkipsWhenClaimHostIsNotALocalRoute(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t)
+ addRoute(t, reg, "go.tito.io", "tito", "classic")
+ putClaim(t, s, Claim{
+ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
+ CreatedAt: "2026-09-03T00:00:00Z",
+ Paths: map[string]string{"paulca": "go.tito.io"},
+ })
+
+ if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
+ t.Fatalf("route_paths = %v, want nothing — ti.to is not a route on this box", got)
+ }
+}
+
+// TestReconcileCapsPathRows: a hand-written claim above the cap is truncated
+// deterministically (sorted key order), never silently and never at random.
+func TestReconcileCapsPathRows(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t)
+ addRoute(t, reg, "ti.to", "tito", "classic")
+ addRoute(t, reg, "go.tito.io", "tito", "classic")
+ big := make(map[string]string, MaxPathsPerClaim+10)
+ for i := 0; i < MaxPathsPerClaim+10; i++ {
+ big["acct"+strconv.Itoa(i)] = "go.tito.io"
+ }
+ putClaim(t, s, Claim{
+ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
+ CreatedAt: "2026-09-03T00:00:00Z",
+ Paths: big,
+ })
+
+ if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ got := pathsByPrefix(t, reg, "ti.to")
+ if len(got) != MaxPathsPerClaim {
+ t.Fatalf("derived %d rows, want the cap of %d", len(got), MaxPathsPerClaim)
+ }
+ // WHICH prefixes survive, not just how many. The count alone passes with
+ // the sort deleted, and the sort is the safety-critical half: every box
+ // folds the same claim independently, so a truncation that varied with
+ // Go's map iteration order would let the same URL resolve to different
+ // backends depending on which edge answered.
+ want := make([]string, 0, len(big))
+ for p := range big {
+ want = append(want, p)
+ }
+ sort.Strings(want)
+ want = want[:MaxPathsPerClaim]
+ for _, p := range want {
+ if _, ok := got[p]; !ok {
+ t.Fatalf("prefix %q was dropped; the surviving set must be the first %d in SORTED order, identically on every box", p, MaxPathsPerClaim)
+ }
+ }
+}
+
+// TestReconcileSkipsUnreadyTarget: a target route that exists on this box but
+// is not ready yet contributes no row (design spec §4, "a target that is not
+// a local READY route contributes no row"). The edge filters unready routes
+// out of its map anyway, so traffic already fell through safely — what this
+// gate buys is that `carlos routes` and the edge agree about which prefixes
+// are actually serving, and that surface is the one built for diagnosing a
+// flip.
+func TestReconcileSkipsUnreadyTarget(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t)
+ addRoute(t, reg, "ti.to", "tito", "classic")
+ if err := reg.Upsert(registry.Route{
+ Host: "go.tito.io", App: "classic", Account: "tito", Kind: "instance", Status: "provisioning",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ putClaim(t, s, Claim{
+ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
+ CreatedAt: "2026-09-03T00:00:00Z",
+ Paths: map[string]string{"paulca": "go.tito.io"},
+ })
+
+ if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
+ t.Fatalf("route_paths = %v, want no row for a target that is not ready", got)
+ }
+}
+
+// TestReconcileLogsUnresolvedTargetsOncePerHost is the critical-silence fix.
+// A target homed on ANOTHER BOX is the likeliest real-world refusal in this
+// feature — `ti.to` and `go.tito.io` are two very different apps — and it
+// used to be the ONLY refusal in the path block that said nothing at all, so
+// a migration could be published, accepted, and never route, forever, with
+// no signal. It must log; and because this pass runs every ~2s against a map
+// that may hold 10k entries, it must log ONE line per host per pass.
+func TestReconcileLogsUnresolvedTargetsOncePerHost(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t)
+ addRoute(t, reg, "ti.to", "tito", "classic")
+ putClaim(t, s, Claim{
+ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
+ CreatedAt: "2026-09-03T00:00:00Z",
+ Paths: map[string]string{
+ "paulca": "elsewhere.example",
+ "bigcorp": "elsewhere.example",
+ "acme": "also-elsewhere.example",
+ },
+ })
+
+ var buf bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&buf)
+ defer log.SetOutput(old)
+ if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ log.SetOutput(old)
+
+ out := buf.String()
+ if n := strings.Count(out, "name no local ready route"); n != 1 {
+ t.Fatalf("logged the unresolved-target line %d times, want exactly one per host per pass:\n%s", n, out)
+ }
+ for _, want := range []string{"ti.to", "3 prefix(es)", "elsewhere.example", "homed on another box"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("unresolved-target log is missing %q:\n%s", want, out)
+ }
+ }
+}
diff --git a/internal/domains/claimcache_test.go b/internal/domains/claimcache_test.go
index 985dffad1..6b74485cb 100644
--- a/internal/domains/claimcache_test.go
+++ b/internal/domains/claimcache_test.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"os"
+ "reflect"
"testing"
"time"
@@ -117,7 +118,7 @@ func TestClaimCacheAgreesWithTheUncachedFold(t *testing.T) {
t.Errorf("cached fold invented %s", got.Host)
continue
}
- if got != want {
+ if !reflect.DeepEqual(got, want) {
t.Errorf("%s: cached %+v, uncached %+v", got.Host, got, want)
}
}
diff --git a/internal/domains/claims.go b/internal/domains/claims.go
index ec8c8533e..935a8b062 100644
--- a/internal/domains/claims.go
+++ b/internal/domains/claims.go
@@ -55,6 +55,22 @@ type Claim struct {
// the same change that first writes it.
Wildcard bool `json:"wildcard,omitempty"`
Catchall bool `json:"catchall,omitempty"`
+ // Paths splits this hostname's traffic by leading URL segment: a bare
+ // first segment ("paulca", never "/paulca") mapped to the HOSTNAME of the
+ // route that serves it. Absent or empty means this host routes by
+ // hostname alone — the behaviour of every host today, and the reason this
+ // field is additive in the strongest sense: no row, no map, no change.
+ //
+ // This is a THIRD claim capability alongside Wildcard/Catchall, and it is
+ // declared here for the reason stated above them — converge preserves only
+ // DECLARED fields, so an undeclared field would be stripped by any binary
+ // that rewrote the claim.
+ //
+ // The writer is the tenant's own platform, publishing the whole map
+ // regenerated from its own database. Deltas were rejected deliberately: a
+ // lost delta desyncs the two systems silently, whereas a wholesale
+ // republish is always a full repair.
+ Paths map[string]string `json:"paths,omitempty"`
}
// ErrHostUnavailable is returned by AttachClaim when host is already claimed
@@ -316,3 +332,132 @@ func SetClaimCapabilities(ctx context.Context, s release.Store, host, account, a
}
}
}
+
+// MaxPathsPerClaim bounds a claim's prefix map. Unlike maxWildcardDomains
+// this defends no external budget — there is no ACME equivalent for a path
+// row — it bounds the claim object's size and the per-box fold that reads it
+// every fast-converge tick. At roughly 40 bytes an entry this is a ~400KB
+// object refolded every couple of seconds, which is comfortable; sharding a
+// host's map across objects is deliberately not built until a real host
+// approaches this number.
+const MaxPathsPerClaim = 10000
+
+// ErrTooManyPaths is returned by SetClaimPaths for a map above
+// MaxPathsPerClaim. Refused whole: a silently truncated map would route a
+// subset of accounts and look successful.
+var ErrTooManyPaths = errors.New("too many path prefixes")
+
+// ErrBadPathPrefix is returned by SetClaimPaths for a key that is not a bare
+// 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
+// a first segment could never match and must not be stored.
+func validPathPrefix(p string) bool {
+ if p == "" || len(p) > 255 {
+ return false
+ }
+ for i := 0; i < len(p); i++ {
+ c := p[i]
+ switch {
+ case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
+ case c == '-' || c == '_' || c == '.' || c == '~':
+ default:
+ return false
+ }
+ }
+ // "." and ".." would be normalised away by any path cleaner between the
+ // browser and here, so a row keyed on either is unreachable by
+ // construction.
+ return p != "." && p != ".."
+}
+
+// SetClaimPaths is the ONLY writer of a claim's Paths field, the same
+// single-writer discipline SetClaimCapabilities holds over Wildcard/Catchall
+// and for the same reason: AttachClaim's converge repoints Route and nothing
+// else, so it can preserve this map but must never invent or strip it.
+//
+// The write is WHOLESALE — paths replaces the stored map rather than merging
+// into it. That is the contract the caller's platform relies on: it
+// regenerates the map from its own database and publishes the result, so an
+// entry it no longer lists must disappear. Merging would make rollback
+// impossible to express.
+//
+// Owner-gated exactly as SetClaimCapabilities is: a caller who does not hold
+// the claim gets the neutral ErrHostUnavailable, and a missing claim answers
+// identically, so this door is not an existence oracle.
+func SetClaimPaths(ctx context.Context, s release.Store, host, account, app string, paths map[string]string) (changed bool, err error) {
+ if len(paths) > MaxPathsPerClaim {
+ return false, ErrTooManyPaths
+ }
+ for prefix, target := range paths {
+ if !validPathPrefix(prefix) {
+ return false, ErrBadPathPrefix
+ }
+ // The target must be a plausible hostname. Whether it is a route this
+ // 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: %w", prefix, target, ErrBadPathTarget)
+ }
+ }
+ for attempt := 0; ; attempt++ {
+ cur, etag, err := getClaimETag(ctx, s, host)
+ if errors.Is(err, os.ErrNotExist) {
+ return false, ErrHostUnavailable
+ } else if err != nil {
+ return false, err
+ }
+ if cur.Account != account || cur.App != app {
+ return false, ErrHostUnavailable
+ }
+ if samePaths(cur.Paths, paths) {
+ return false, nil
+ }
+ next := *cur
+ if len(paths) == 0 {
+ next.Paths = nil // omitempty: an empty map and no map are one state
+ } else {
+ next.Paths = make(map[string]string, len(paths))
+ for k, v := range paths {
+ next.Paths[k] = v
+ }
+ }
+ body, err := json.Marshal(next)
+ if err != nil {
+ return false, err
+ }
+ if _, err := s.Put(ctx, ClaimKey(host), body, etag); err == nil {
+ return true, nil
+ } else if errors.Is(err, release.ErrCASConflict) && attempt < 3 {
+ continue
+ } else {
+ return false, err
+ }
+ }
+}
+
+// samePaths reports whether two prefix maps are equal, treating nil and empty
+// as the same state. Republishing an unchanged map is the COMMON case — the
+// caller republishes wholesale as a repair — so this is what keeps a repair
+// from costing a bucket write and a fleet-wide edge reload.
+func samePaths(a, b map[string]string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for k, v := range a {
+ if b[k] != v {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/domains/claims_test.go b/internal/domains/claims_test.go
index 72513ef18..0a3fd8063 100644
--- a/internal/domains/claims_test.go
+++ b/internal/domains/claims_test.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"os"
+ "reflect"
"strings"
"testing"
@@ -53,7 +54,7 @@ func TestAttachClaimIdempotent(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if *before != *after {
+ if !reflect.DeepEqual(before, after) {
t.Fatalf("object changed on idempotent re-attach: before=%+v after=%+v", before, after)
}
}
@@ -117,7 +118,7 @@ func TestAttachClaimOtherOwnerRefused(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if *before != *after {
+ if !reflect.DeepEqual(before, after) {
t.Fatalf("object changed on refused attach: before=%+v after=%+v", before, after)
}
}
diff --git a/internal/domains/paths_test.go b/internal/domains/paths_test.go
new file mode 100644
index 000000000..7e172915b
--- /dev/null
+++ b/internal/domains/paths_test.go
@@ -0,0 +1,254 @@
+package domains
+
+import (
+ "context"
+ "errors"
+ "strconv"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// TestSetClaimPathsRoundTrip: the map written is the map read back.
+func TestSetClaimPathsRoundTrip(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
+ map[string]string{"paulca": "go.tito.io"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !changed {
+ t.Fatal("changed = false on the first write of a paths map")
+ }
+ c, err := GetClaim(ctx, s, "ti.to")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.Paths["paulca"] != "go.tito.io" {
+ t.Fatalf("paths = %v, want paulca -> go.tito.io", c.Paths)
+ }
+}
+
+// TestSetClaimPathsIsWholesale: publishing is a REPLACE, not a merge. Tito
+// regenerates the whole map from its own database, so an account it no longer
+// lists must disappear — that is how a rollback reaches the edge.
+func TestSetClaimPathsIsWholesale(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
+ map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
+ map[string]string{"paulca": "go.tito.io"}); err != nil {
+ t.Fatal(err)
+ }
+ c, err := GetClaim(ctx, s, "ti.to")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, still := c.Paths["bigcorp"]; still {
+ t.Fatal("bigcorp survived a republish that omitted it — publishing must replace, not merge")
+ }
+}
+
+// TestSetClaimPathsEmptyClearsTheMap: the final cutover. When every account
+// is across, Tito repoints the host's own route and publishes an empty map.
+func TestSetClaimPathsEmptyClearsTheMap(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
+ map[string]string{"paulca": "go.tito.io"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", nil); err != nil {
+ t.Fatal(err)
+ }
+ c, err := GetClaim(ctx, s, "ti.to")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(c.Paths) != 0 {
+ t.Fatalf("paths = %v, want empty after publishing an empty map", c.Paths)
+ }
+}
+
+// TestSetClaimPathsIsOwnerGated: a caller who does not hold the claim gets
+// the same neutral ErrHostUnavailable the attach door uses — no existence
+// oracle, byte-identical to the answer for a claim that does not exist.
+func TestSetClaimPathsIsOwnerGated(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "someone-else", "classic",
+ map[string]string{"paulca": "evil.example"}); !errors.Is(err, ErrHostUnavailable) {
+ t.Fatalf("err = %v, want ErrHostUnavailable for a non-owner", err)
+ }
+ if _, err := SetClaimPaths(ctx, s, "never-claimed.example", "tito", "classic",
+ map[string]string{"a": "b.example.com"}); !errors.Is(err, ErrHostUnavailable) {
+ t.Fatalf("err = %v, want the identical ErrHostUnavailable for an absent claim", err)
+ }
+}
+
+// TestSetClaimPathsRejectsUnnormalisedTarget: a target that is not a
+// normalised hostname (no interior dot, wrong case, trailing dot, ...) is
+// 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())
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(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)
+ }
+ }
+}
+
+// TestSetClaimPathsUnchangedIsNoWrite: republishing an identical map must not
+// burn a bucket write. Tito republishes wholesale as a repair, so the no-op
+// case is the common one.
+func TestSetClaimPathsUnchangedIsNoWrite(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ m := map[string]string{"paulca": "go.tito.io"}
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m); err != nil {
+ t.Fatal(err)
+ }
+ changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if changed {
+ t.Fatal("changed = true for an identical republish — a no-op must not write")
+ }
+}
+
+// TestSetClaimPathsRefusesOversizeMap: the cap is enforced at the door, so an
+// oversize map is refused whole rather than silently truncated into the
+// bucket. (The reconcile pass caps again, defensively, for hand-written
+// claims — see Task 3.)
+func TestSetClaimPathsRefusesOversizeMap(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ big := make(map[string]string, MaxPathsPerClaim+1)
+ for i := 0; i <= MaxPathsPerClaim; i++ {
+ big["acct"+strconv.Itoa(i)] = "go.tito.io"
+ }
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", big); !errors.Is(err, ErrTooManyPaths) {
+ t.Fatalf("err = %v, want ErrTooManyPaths", err)
+ }
+}
+
+// TestSetClaimPathsRejectsMalformedPrefixes: a prefix is a bare segment. A
+// leading slash, an embedded slash, or an empty key would each produce a row
+// the edge could never match, so they are refused at the door rather than
+// stored as dead weight.
+func TestSetClaimPathsRejectsMalformedPrefixes(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ for _, bad := range []string{"/paulca", "paulca/awesomeconf", "", "with space"} {
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
+ map[string]string{bad: "go.tito.io"}); !errors.Is(err, ErrBadPathPrefix) {
+ t.Fatalf("prefix %q: err = %v, want ErrBadPathPrefix", bad, err)
+ }
+ }
+}
+
+// TestAttachConvergePreservesPaths: a repoint must not strip the prefix map.
+func TestAttachConvergePreservesPaths(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
+ map[string]string{"paulca": "go.tito.io"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := AttachClaim(ctx, s, Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti-to-v2.tito.io", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ c, err := GetClaim(ctx, s, "ti.to")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.Paths["paulca"] != "go.tito.io" {
+ t.Fatalf("paths = %v, want the map preserved across a converge repoint", c.Paths)
+ }
+}
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 9c5a1bc42..5d3ee5b39 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -36,6 +36,7 @@ import (
"net/textproto"
"os"
"os/signal"
+ "path"
"path/filepath"
"slices"
"strconv"
@@ -254,6 +255,18 @@ type upstream struct {
// the flag exists so wildcard parent derivation (currentHosts) never
// treats a customer hostname as a platform host.
alias bool
+ // paths, when non-nil, splits this hostname's traffic by leading URL
+ // segment: a bare first segment ("paulca") mapped to the HOSTNAME of the
+ // route that serves it, resolved against the same live map at request
+ // time. Nil for every host that does not use prefix routing, which is
+ // every host on the platform until a claim publishes a map — the nil
+ // check in lookupUpstreamPath is the entire cost this feature imposes on
+ // everyone else.
+ //
+ // Hostnames rather than resolved upstreams, deliberately: an upstream
+ // captured here would be a stale copy of the target's entry the moment
+ // the target's route changed within the same map build.
+ paths map[string]string
}
// New builds an Edge over reg and performs the startup reload. act may be nil
@@ -581,6 +594,33 @@ func (e *Edge) Reload() error {
}
m[t.Host] = upstream{proxy: proxy, route: t, hibernating: hib}
}
+ // Path routes, folded BEFORE aliases so an alias of a prefix-routed
+ // hostname inherits its map along with everything else it copies — an
+ // alias is meant to be indistinguishable from its primary, and a
+ // hostname that routed by prefix under one name but not the other would
+ // be a trap.
+ //
+ // Targets are recorded as hostnames, not resolved here: every route is
+ // already in m by this point, but resolving now would freeze a copy of
+ // the target's entry, and the target's own row may still be rewritten by
+ // the alias fold below.
+ paths, err := e.reg.ListPaths()
+ if err != nil {
+ return err
+ }
+ for _, p := range paths {
+ up, ok := m[p.Host]
+ if !ok {
+ // The hostname has no route on this box. The reconcile pass
+ // should not have produced this row; nothing to attach it to.
+ continue
+ }
+ if up.paths == nil {
+ up.paths = make(map[string]string)
+ }
+ up.paths[p.Prefix] = p.Target
+ m[p.Host] = up
+ }
// Attached hostnames (#151): each alias resolves to the primary's OWN
// entry — same proxy value, same route (so activator keying, version
// header, and purge tenancy are all the primary's). Routes always win
@@ -849,13 +889,15 @@ func (e *Edge) deploymentHost(host string) bool {
return e.Allowed(host) || e.homes.Load().KnownHost(host)
}
-// lookupUpstream resolves host to its upstream entry — the request-routing
-// lookup ServeHTTP uses (NOT Allowed's autocert HostPolicy — see the comment
-// there). An exact match (a real route, or a plain #151 alias landed in the
-// same map) always wins; on a miss, a one-label wildcard key folds in:
-// "*.<parent>" is the shape Task 3's reconcile pass derives from a claim's
-// catch-all row, and it lands in the SAME map as every other alias
-// (up.alias = true, primary's route + proxy verbatim) so this fallback
+// lookupIn resolves host to its upstream entry within a given map — the
+// HOST half of the request-routing lookup (NOT Allowed's autocert HostPolicy
+// — see the comment there). ServeHTTP does not call it directly: it calls
+// lookupUpstreamPath, which wraps this and then resolves a leading path
+// segment for the hostnames that carry a prefix map. An exact match (a real route, or a plain #151
+// alias landed in the same map) always wins; on a miss, a one-label wildcard
+// key folds in: "*.<parent>" is the shape Task 3's reconcile pass derives
+// from a claim's catch-all row, and it lands in the SAME map as every other
+// alias (up.alias = true, primary's route + proxy verbatim) so this fallback
// resolves to the primary's own entry — the identical map value the
// #151/#152 agreed rule requires, not a value that merely behaves the same.
// A wildcard matches exactly one label, mirroring the TLS cert: host[i:]
@@ -863,8 +905,7 @@ func (e *Edge) deploymentHost(host string) bool {
// (a.b.woodstar.app) composes "*.b.woodstar.app" and misses, and the apex
// itself (woodstar.app) composes "*.app" — never its own "*.woodstar.app" —
// so it can never match its own wildcard.
-func (e *Edge) lookupUpstream(host string) (upstream, bool) {
- m := e.current()
+func lookupIn(m map[string]upstream, host string) (upstream, bool) {
if up, ok := m[host]; ok {
return up, true
}
@@ -876,6 +917,201 @@ func (e *Edge) lookupUpstream(host string) (upstream, bool) {
return upstream{}, false
}
+// lookupUpstream resolves a hostname alone. Kept as the primitive because not
+// every caller is answering "where does this REQUEST go" — the :80->:443
+// routability check in httpFallbackHandler asks only whether this box serves
+// the hostname at all, and giving that question a path would make a
+// hostname's routability depend on which URL happened to ask.
+func (e *Edge) lookupUpstream(host string) (upstream, bool) {
+ return lookupIn(e.current(), host)
+}
+
+// lookupUpstreamPath resolves a request: the hostname exactly as
+// lookupUpstream does, then — only for a hostname carrying a prefix map — the
+// first path segment.
+//
+// Both lookups read ONE snapshot of the map. Calling lookupUpstream and then
+// current() again would let a reload land between them and resolve a prefix
+// against a different table than the host came from.
+//
+// Every miss falls through to the hostname's own route rather than failing:
+// an unlisted prefix, a dangling target, a request for "/". A prefix map is a
+// migration in progress, and the un-migrated answer is always the safe one.
+//
+// ATTRIBUTION: a path hit returns the TARGET's upstream entry wholesale, so
+// everything downstream that reads up.route reads the target's row, not the
+// requested hostname's. Four consequences, named here because this is where a
+// reader will look for them:
+//
+// 1. Usage metering bills the TARGET's route, account and app (the
+// UsageMeter wrap in ServeHTTP keys on up.route.Host). Correct: the
+// target's backend did the work and burned the bytes. Pinned by
+// TestPathRoutedRequestMetersAgainstTheTarget — a later "fix" that
+// attributes to the requested hostname would double-count nothing and
+// bill the wrong app.
+// 2. The X-Carlos-Version response header reports the TARGET's release.
+// Correct: it names the code that produced the response.
+// 3. Purge tenancy is the TARGET's app and account (purgeModifier is built
+// from up.route). Correct as tenancy — but it is also a real GAP, see
+// below.
+// 4. Activator keying and hibernation are the TARGET's (up.hibernating and
+// up.route come from the same entry), so a request for a flipped prefix
+// wakes the target instance, not the requested hostname's. Correct: the
+// target is the process that has to be awake to answer.
+//
+// The gap in (3): the response CACHE keys on the REQUESTED host
+// (CacheKey{Host: host} in ServeHTTP), while purgeModifier resolves the purge
+// host from the target's route. So a path-routed app emitting Carlos-Purge
+// purges under go.tito.io while its entry is stored under ti.to, and nothing
+// is dropped; the documented Carlos-Purge-Host: ti.to escape hatch does not
+// help either, because resolvePurgeHost requires the override to name a host
+// in the SAME app — and path routing exists precisely to cross an app
+// boundary. Aliases never hit this: an alias key maps to the primary's own
+// route, so the app comparison passes trivially.
+//
+// The bound on that gap: a path-routed response can go stale for at most the
+// cache's max TTL, and only for responses that opted into caching with a
+// positive max-age. Uncached responses are unaffected. Widening the purge
+// tenancy model to follow a prefix map is a design change this feature did
+// not make; it is recorded as a known gap in design spec §11.
+func (e *Edge) lookupUpstreamPath(host, urlPath string) (upstream, bool) {
+ m := e.current()
+ up, ok := lookupIn(m, host)
+ if !ok || up.paths == nil {
+ return up, ok
+ }
+ // BOTH FORMS, cleaned first. The prefix map is consulted for the
+ // path.Clean'd first segment and then, if that missed and the two differ,
+ // for the raw one. When both name a prefix, the cleaned one wins.
+ //
+ // Neither form alone is safe, and they fail in OPPOSITE directions. The
+ // invariant being protected is one-way: during a reversible dual-run
+ // migration, the app an account was flipped AWAY from must never answer
+ // for that account. Serving a not-yet-flipped account from the NEW app is
+ // the benign direction — at worst a 404 while the migration runs.
+ //
+ // - RAW alone misses "//paulca/foo". Rails and most other backends
+ // collapse that to "/paulca/foo" and serve it, so the request falls
+ // through to the legacy app, which then answers for a flipped
+ // account. One character, silent.
+ // - CLEANED alone misses "/paulca/../bob", which resolves to "bob". A
+ // backend that does NOT collapse ".." — or that routes on the first
+ // segment with a glob like "/:account/*rest" — then serves paulca's
+ // page from the legacy app. The same bypass, reached the other way.
+ //
+ // Taking a hit from either form closes both: "//paulca/foo" hits on the
+ // cleaned form, "/paulca/../bob" hits on the raw one, and a flipped
+ // account reaches its new app whatever the backend does with the odd
+ // shape. The cost is that "/paulca/../bob" reaches the NEW app carrying
+ // "/paulca/../bob", which a normalising target resolves to "/bob" and
+ // 404s — an account it was not given. Benign direction, and deliberate.
+ //
+ // Residue, measured rather than assumed. None of it crosses the
+ // invariant above, and it is written down so nobody has to re-derive it:
+ //
+ // - "/%2e%2e/bob": r.URL.Path is already percent-DECODED, so this
+ // cleans to "/bob" and matches "bob". The upstream receives the raw
+ // "/%2e%2e/bob" and will most likely 404 it. Harmless.
+ // - "/paulca%2ffoo": decodes to "/paulca/foo", so both forms match
+ // "paulca" and the request reaches paulca's new app — while a strict
+ // backend treats the upstream path as the SINGLE segment
+ // "paulca/foo". A mis-flip toward the NEW app: the safe direction.
+ // - A prefix literally named "." or ".." cannot exist: validPathPrefix
+ // (internal/domains/claims.go) refuses both, so the raw form of
+ // "/../bob" ("..") can never match anything.
+ //
+ // Do not collapse this back to a single form. Whichever one you keep,
+ // you reopen the bypass the other one closes.
+ //
+ // The scope of the guarantee, stated so it is not over-read: the two
+ // forms model percent-decoding and dot-segment removal, and the
+ // invariant holds across every combination of a backend doing or not
+ // doing those two. It is NOT a claim that no normalisation exists. A
+ // backend that folds case ("/PAULCA/foo"), strips servlet-style path
+ // parameters ("/paulca;jsessionid=x/foo"), accepts backslash
+ // separators, strips trailing dots, or double-decodes could resolve a
+ // path neither form matches into a flipped account's subtree. Rails
+ // does none of these — Rack keeps ";" in PATH_INFO and routing is
+ // case-sensitive — so Tito is not exposed, but a future consumer with
+ // different normalisation would need this reasoning redone rather than
+ // inherited.
+ //
+ // No guard is needed for the empty segment ("/", "//", ""): validPathPrefix
+ // refuses an empty prefix, so no row can be keyed on one and both lookups
+ // below simply miss, falling through to the host's own route.
+ seg := firstSegmentClean(urlPath)
+ raw := firstSegment(urlPath)
+ target, ok := up.paths[seg]
+ if !ok && raw != seg {
+ target, ok = up.paths[raw]
+ }
+ if !ok {
+ return up, true
+ }
+ // m[target], not lookupIn(m, target): the TARGET is resolved exactly,
+ // while the host half above also accepts a one-label wildcard key. The
+ // asymmetry is deliberate and it is documented rather than removed —
+ // route_paths targets are hostnames the reconcile pass verified against a
+ // real local route (aliases.go), and a real route is always an exact key,
+ // so a target reachable ONLY through "*.parent" cannot be produced by any
+ // supported writer. If one ever were, it would miss here and fall through
+ // to the host's own route, which is this function's safe direction
+ // anyway.
+ tu, ok := m[target]
+ if !ok {
+ return up, true
+ }
+ // ONE HOP, deliberately: if tu itself carries a prefix map, it is NOT
+ // re-resolved against this request's path. A prefix selects a route, and
+ // the selected route's own map belongs to ITS hostname's traffic, not to
+ // this one's. Do not add recursion here — chaining buys nothing the
+ // publisher cannot express by pointing the prefix at the final target
+ // directly, and it would introduce a cycle to detect.
+ return tu, true
+}
+
+// firstSegment returns the first path segment of an absolute URL path,
+// without slashes, splitting the path EXACTLY as it arrived:
+// "/paulca/awesomeconf" -> "paulca". "" for "/", for the empty string, and
+// for anything not starting with "/".
+//
+// Returning the segment rather than testing a string prefix is what makes
+// matching segment-bounded: "/paulcarson" yields "paulcarson", which is not
+// equal to "paulca", so a longer account name can never be dragged across a
+// migration by a shorter one.
+//
+// This is the RAW half of the matcher. See lookupUpstreamPath's match block
+// for why both halves exist and why neither is safe alone.
+func firstSegment(p string) string {
+ if p == "" || p[0] != '/' {
+ return ""
+ }
+ p = p[1:]
+ if i := strings.IndexByte(p, '/'); i >= 0 {
+ p = p[:i]
+ }
+ return p
+}
+
+// firstSegmentClean is firstSegment over the path.Clean'd path, so leading
+// empty segments and dot segments resolve before the split:
+//
+// "//paulca/foo" -> "paulca" (leading empty segments collapse)
+// "/./paulca" -> "paulca" (dot segment collapses)
+// "/paulca/../bob" -> "bob" (".." resolves)
+// "/paulca/.." -> "" (resolves to "/", the host's own root)
+// "/paulca/" -> "paulca" (a trailing slash is not a second segment)
+//
+// This is the CLEANED half of the matcher; the raw half is firstSegment.
+func firstSegmentClean(p string) string {
+ if p == "" || p[0] != '/' {
+ return ""
+ }
+ // Clean of a rooted path is rooted, and is "/" when everything resolves
+ // away — so the slice below is always safe and yields "" for the root.
+ return firstSegment(path.Clean(p))
+}
+
func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
@@ -927,7 +1163,7 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// nothing else.
r, hopped := e.acceptHop(r, host)
- up, ok := e.lookupUpstream(host)
+ up, ok := e.lookupUpstreamPath(host, r.URL.Path)
if !ok {
if hopped {
// A PROVEN hop from a sibling edge, for a host that has moved (or
diff --git a/internal/edge/path_e2e_test.go b/internal/edge/path_e2e_test.go
new file mode 100644
index 000000000..67ed8bc26
--- /dev/null
+++ b/internal/edge/path_e2e_test.go
@@ -0,0 +1,117 @@
+package edge
+
+import (
+ "context"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// TestPathFlipEndToEnd walks the whole chain a real flip takes — claim
+// published, reconcile pass, fingerprint moves, edge reloads, traffic changes
+// — and then walks it backwards. Each earlier task proved one layer against a
+// hand-written input; this is the only test that proves they compose.
+//
+// The registry write and the edge reload are driven directly rather than
+// waited on: the 2s tick is a latency bound, not a correctness property, and
+// polling it would make this test slow and flaky. What must be true is that
+// the fingerprint MOVES, which is asserted rather than assumed.
+func TestPathFlipEndToEnd(t *testing.T) {
+ ctx := context.Background()
+ classic, _ := hostEchoBackend(t, "classic")
+ golive, _ := hostEchoBackend(t, "go")
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "ti.to", Kind: "service", Account: "tito", App: "classic",
+ Addr: classic.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "go.tito.io", Kind: "service", Account: "tito", App: "go",
+ Addr: golive.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ store := release.NewDirStore(t.TempDir())
+ if _, _, err := domains.AttachClaim(ctx, store, domains.Claim{
+ Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
+ Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ defer srv.Close()
+
+ // Before the flip: everything is classic.
+ if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "classic" {
+ t.Fatalf("before the flip: body = %q, want classic", body)
+ }
+
+ fpBefore, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // The flip: Tito publishes its map, the pass folds it, the edge reloads.
+ if _, err := domains.SetClaimPaths(ctx, store, "ti.to", "tito", "classic",
+ map[string]string{"paulca": "go.tito.io"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := domains.ReconcileAliases(ctx, store, reg, time.Now, nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ fpAfter, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fpBefore == fpAfter {
+ t.Fatal("fingerprint did not move across a flip — the edge's watcher would never reload")
+ }
+ if err := e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+
+ // After the flip: that account is on Go, its neighbours are not.
+ if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "go" {
+ t.Fatalf("after the flip: body = %q, want go", body)
+ }
+ if _, body := getHostPath(t, srv.URL, "ti.to", "/someoneelse/event"); body != "classic" {
+ t.Fatalf("after the flip: a neighbouring account moved too, body = %q", body)
+ }
+ if _, body := getHostPath(t, srv.URL, "ti.to", "/paulcarson"); body != "classic" {
+ t.Fatalf("after the flip: a longer account name was dragged along, body = %q", body)
+ }
+
+ // The rollback: republish without that account.
+ if _, err := domains.SetClaimPaths(ctx, store, "ti.to", "tito", "classic", nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := domains.ReconcileAliases(ctx, store, reg, time.Now, nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "classic" {
+ t.Fatalf("after the rollback: body = %q, want classic", body)
+ }
+ rows, err := reg.ListPaths()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) != 0 {
+ t.Fatalf("after the rollback: %d path rows remain, want 0", len(rows))
+ }
+}
diff --git a/internal/edge/path_test.go b/internal/edge/path_test.go
new file mode 100644
index 000000000..b6c48e3ba
--- /dev/null
+++ b/internal/edge/path_test.go
@@ -0,0 +1,471 @@
+package edge
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// upsertPath writes one route_paths row, the shape the domains reconcile pass
+// derives from a claim's Paths map.
+func upsertPath(t *testing.T, reg *registry.Registry, host, prefix, target string) {
+ t.Helper()
+ if err := reg.UpsertPath(registry.RoutePath{
+ Host: host, Prefix: prefix, Target: target, Account: "acct", App: "app",
+ }); err != nil {
+ t.Fatalf("upsert path %s/%s -> %s: %v", host, prefix, target, err)
+ }
+}
+
+// splitHost stands up one hostname whose default route is "classic" and whose
+// "flipped" prefix reaches a second route, returning the live test server.
+func splitHost(t *testing.T) *httptest.Server {
+ t.Helper()
+ classic, _ := hostEchoBackend(t, "classic")
+ golive, _ := hostEchoBackend(t, "go")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "ti.to", Kind: "service",
+ Addr: classic.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "go.tito.io", Kind: "service",
+ Addr: golive.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ upsertPath(t, reg, "ti.to", "flipped", "go.tito.io")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+// TestPathRouteReachesTarget is the feature: a flipped prefix reaches the
+// other route's backend while the same hostname's other traffic does not.
+func TestPathRouteReachesTarget(t *testing.T) {
+ srv := splitHost(t)
+ for _, tc := range []struct{ path, want string }{
+ {"/flipped", "go"},
+ {"/flipped/awesomeconf", "go"},
+ {"/flipped/awesomeconf/admin", "go"},
+ {"/notflipped", "classic"},
+ {"/notflipped/event", "classic"},
+ {"/", "classic"},
+ {"", "classic"},
+ } {
+ code, body := getHostPath(t, srv.URL, "ti.to", tc.path)
+ if code != http.StatusOK {
+ t.Fatalf("%s: status = %d, want 200", tc.path, code)
+ }
+ if body != tc.want {
+ t.Fatalf("%s: body = %q, want %q", tc.path, body, tc.want)
+ }
+ }
+}
+
+// TestPathMatchIsSegmentBounded is the boundary pin. A prefix match must be
+// segment-wise equality, never a string prefix — "flippedxyz" is a different
+// account from "flipped" and must not be dragged across the migration with it.
+func TestPathMatchIsSegmentBounded(t *testing.T) {
+ srv := splitHost(t)
+ for _, p := range []string{"/flippedxyz", "/flipped-two", "/flippedxyz/event"} {
+ code, body := getHostPath(t, srv.URL, "ti.to", p)
+ if code != http.StatusOK {
+ t.Fatalf("%s: status = %d, want 200", p, code)
+ }
+ if body != "classic" {
+ t.Fatalf("%s: body = %q, want classic — a prefix matches whole segments only", p, body)
+ }
+ }
+}
+
+// TestPathFoldOrderAliasInheritsPrefixMap pins the fold order in Reload:
+// path routes are folded into the map BEFORE aliases (edge.go, the comment
+// above the path fold), so an alias of a prefix-routed hostname inherits its
+// prefix map along with everything else it copies. Swap that order and this
+// test is the one that catches it — every other test in this file talks to
+// the primary hostname directly and would keep passing.
+func TestPathFoldOrderAliasInheritsPrefixMap(t *testing.T) {
+ classic, _ := hostEchoBackend(t, "classic")
+ golive, _ := hostEchoBackend(t, "go")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "ti.to", Kind: "service",
+ Addr: classic.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "go.tito.io", Kind: "service",
+ Addr: golive.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ upsertPath(t, reg, "ti.to", "flipped", "go.tito.io")
+ upsertAlias(t, reg, "ti-to.alias.example", "ti.to")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ code, body := getHostPath(t, srv.URL, "ti-to.alias.example", "/flipped")
+ if code != http.StatusOK || body != "go" {
+ t.Fatalf("alias /flipped: status=%d body=%q, want 200/go — the alias must inherit ti.to's prefix map, which requires path routes to fold BEFORE aliases in Reload", code, body)
+ }
+}
+
+// TestPathRouteDoesNotStripThePrefix: the upstream sees the original URL. The
+// flipped and unflipped URL spaces must be identical, which is the whole
+// point of a seamless migration.
+func TestPathRouteDoesNotStripThePrefix(t *testing.T) {
+ classic, _ := hostEchoBackend(t, "classic")
+ golive := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(r.URL.Path))
+ }))
+ t.Cleanup(golive.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "ti.to", Kind: "service",
+ Addr: classic.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "go.tito.io", Kind: "service",
+ Addr: golive.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ upsertPath(t, reg, "ti.to", "paulca", "go.tito.io")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf")
+ if body != "/paulca/awesomeconf" {
+ t.Fatalf("upstream saw %q, want /paulca/awesomeconf — the prefix must NOT be stripped", body)
+ }
+}
+
+// TestPathMatchTriesBothFormsOfTheFirstSegment is the flip-bypass pin, and it
+// pins BOTH directions of it, because the matcher's two halves fail opposite
+// ways and each one alone reopens what the other closes.
+//
+// The invariant: during a reversible dual-run migration, the app an account
+// was flipped AWAY from must never answer for that account.
+//
+// - Raw-split alone yields "" for "//flipped/foo", so the request falls
+// through to classic — which most backends then serve as
+// "/flipped/foo". One character, silent. (The original I2 finding.)
+// - Clean-first alone yields "notflipped" for "/flipped/../notflipped", so
+// THAT falls through to classic — and a backend which does not collapse
+// ".." (or which routes "/:account/*rest") serves flipped's page from
+// classic. Same bypass, reached from the other side.
+//
+// Consulting both closes both. The cost is the benign direction: a
+// normalising target gets asked for an account it was not given and 404s.
+func TestPathMatchTriesBothFormsOfTheFirstSegment(t *testing.T) {
+ classic, _ := hostEchoBackend(t, "classic")
+ seen := make(chan string, 16)
+ golive := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ seen <- r.URL.Path
+ w.Write([]byte("go"))
+ }))
+ t.Cleanup(golive.Close)
+ beta, _ := hostEchoBackend(t, "beta")
+
+ reg := openReg(t)
+ for _, rt := range []registry.Route{
+ {Host: "ti.to", Kind: "service", Addr: classic.Listener.Addr().String(), Status: "ready"},
+ {Host: "go.tito.io", Kind: "service", Addr: golive.Listener.Addr().String(), Status: "ready"},
+ {Host: "beta.tito.io", Kind: "service", Addr: beta.Listener.Addr().String(), Status: "ready"},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ upsertPath(t, reg, "ti.to", "flipped", "go.tito.io")
+ upsertPath(t, reg, "ti.to", "onbeta", "beta.tito.io")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ for _, tc := range []struct{ path, want, why string }{
+ // Only the CLEANED form finds the account.
+ {"//flipped", "go", "leading empty segment"},
+ {"//flipped/event", "go", "leading empty segment"},
+ {"///flipped/event", "go", "leading empty segments"},
+ {"/./flipped", "go", "dot segment"},
+ // Only the RAW form finds it. These are the rows that would go to
+ // classic under a clean-only matcher — the regression this test
+ // exists to prevent.
+ {"/flipped/../notflipped", "go", "raw first segment is still flipped"},
+ {"/flipped/..", "go", "raw first segment is still flipped"},
+ {"/flipped/../", "go", "raw first segment is still flipped"},
+ // BOTH forms hit, and they name different prefixes: the CLEANED one
+ // wins, because that is the account a normalising backend will serve.
+ {"/flipped/../onbeta", "beta", "cleaned wins when both hit"},
+ // Neither form names a flipped account: the origin still answers.
+ {"/notflipped", "classic", "no prefix matches"},
+ {"/notflipped/../alsonot", "classic", "no prefix matches either way"},
+ {"/", "classic", "root"},
+ {"//", "classic", "root, however spelled"},
+ // Segment-bounded as ever; normalisation must not have loosened it.
+ {"/flippedxyz", "classic", "segment equality, never HasPrefix"},
+ {"//flippedxyz/event", "classic", "segment equality after cleaning too"},
+ } {
+ code, body := getHostPath(t, srv.URL, "ti.to", tc.path)
+ if code != http.StatusOK {
+ t.Fatalf("%s: status = %d, want 200", tc.path, code)
+ }
+ if body != tc.want {
+ t.Fatalf("%s: body = %q, want %q (%s)", tc.path, body, tc.want, tc.why)
+ }
+ }
+
+ // Matching normalises; PROXYING does not. The upstream must still receive
+ // the request's original, uncleaned path — no stripping, no rewriting, so
+ // the flipped and unflipped URL spaces stay byte-identical (spec §6).
+ for _, want := range []string{"//flipped", "//flipped/event", "///flipped/event", "/./flipped"} {
+ if got := <-seen; got != want {
+ t.Fatalf("upstream saw %q, want the original uncleaned path %q", got, want)
+ }
+ }
+ for _, want := range []string{"/flipped/../notflipped", "/flipped/..", "/flipped/../"} {
+ if got := <-seen; got != want {
+ t.Fatalf("upstream saw %q, want the original uncleaned path %q", got, want)
+ }
+ }
+}
+
+// TestPathPreservesInboundHost: the upstream sees the hostname the browser
+// asked for, matching the alias contract. Tito Go must be able to build
+// absolute URLs that keep the visitor on ti.to.
+func TestPathPreservesInboundHost(t *testing.T) {
+ classic, _ := hostEchoBackend(t, "classic")
+ golive, seenHost := hostEchoBackend(t, "go")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "ti.to", Kind: "service",
+ Addr: classic.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "go.tito.io", Kind: "service",
+ Addr: golive.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ upsertPath(t, reg, "ti.to", "paulca", "go.tito.io")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ getHostPath(t, srv.URL, "ti.to", "/paulca")
+ if *seenHost != "ti.to" {
+ t.Fatalf("upstream saw Host %q, want ti.to", *seenHost)
+ }
+}
+
+// TestPathRowForMissingTargetFallsThrough: a row whose target is not in the
+// live map must serve the hostname's own route, never 404. This is the
+// failure direction the whole design depends on.
+func TestPathRowForMissingTargetFallsThrough(t *testing.T) {
+ classic, _ := hostEchoBackend(t, "classic")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "ti.to", Kind: "service",
+ Addr: classic.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ upsertPath(t, reg, "ti.to", "paulca", "vanished.example")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ code, body := getHostPath(t, srv.URL, "ti.to", "/paulca")
+ if code != http.StatusOK || body != "classic" {
+ t.Fatalf("status=%d body=%q, want 200/classic — a dangling target must fall through", code, body)
+ }
+}
+
+// TestHostsWithoutPathsAreUnaffected: the additive guarantee. Every host on
+// the platform today has no prefix map and must behave exactly as before.
+func TestHostsWithoutPathsAreUnaffected(t *testing.T) {
+ backend, _ := hostEchoBackend(t, "plain")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "app.acct.oncarlos.com", Kind: "service",
+ Addr: backend.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ for _, p := range []string{"/", "/anything", "/deep/path/here"} {
+ code, body := getHostPath(t, srv.URL, "app.acct.oncarlos.com", p)
+ if code != http.StatusOK || body != "plain" {
+ t.Fatalf("%s: status=%d body=%q, want 200/plain", p, code, body)
+ }
+ }
+}
+
+// TestPathRoutedRequestMetersAgainstTheTarget pins the attribution ruling
+// documented on lookupUpstreamPath: a path hit returns the TARGET's upstream
+// entry wholesale, so the usage meter bills the target's route, account and
+// app — not the hostname the browser asked for. That is correct (the target's
+// backend burned the bytes) and it is easy to "fix" the wrong way later, so
+// it is pinned here. The same entry is what makes the version header, the
+// purge tenancy and the activator keying the target's; see that doc comment.
+func TestPathRoutedRequestMetersAgainstTheTarget(t *testing.T) {
+ body := "go"
+ classic, _ := hostEchoBackend(t, "classic")
+ golive, _ := hostEchoBackend(t, body)
+ reg := openReg(t)
+ for _, rt := range []registry.Route{
+ {Host: "ti.to", Kind: "service", Addr: classic.Listener.Addr().String(),
+ Status: "ready", Account: "tito", App: "classic"},
+ {Host: "go.tito.io", Kind: "service", Addr: golive.Listener.Addr().String(),
+ Status: "ready", Account: "tito", App: "titogo"},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ upsertPath(t, reg, "ti.to", "flipped", "go.tito.io")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ meter := NewUsageMeter()
+ e.EnableUsage(meter)
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ if code, got := getHostPath(t, srv.URL, "ti.to", "/flipped/event"); code != http.StatusOK || got != body {
+ t.Fatalf("flipped request = %d %q, want 200 %q", code, got, body)
+ }
+
+ rows := meter.Drain()
+ row, ok := rows["go.tito.io"]
+ if !ok {
+ t.Fatalf("usage rows = %+v, want the TARGET route metered", rows)
+ }
+ if row.Requests != 1 || row.BytesOut != int64(len(body)) {
+ t.Fatalf("go.tito.io row = %+v, want Requests=1 BytesOut=%d", row, len(body))
+ }
+ if row.App != "titogo" {
+ t.Fatalf("go.tito.io row = %+v, want the TARGET's app, not the requested hostname's", row)
+ }
+ if _, ok := rows["ti.to"]; ok {
+ t.Fatalf("usage rows = %+v, want nothing billed to the requested hostname for a path-routed request", rows)
+ }
+}
+
+// TestFirstSegment is the matcher's two halves, tested directly because every
+// routing decision in this feature rests on them. Both columns are pinned for
+// every row, INCLUDING the rows where they disagree — the disagreements are
+// the whole reason lookupUpstreamPath consults both (see its match block).
+func TestFirstSegment(t *testing.T) {
+ for _, tc := range []struct{ in, wantRaw, wantClean string }{
+ // Ordinary shapes: the two halves agree, and this is every real
+ // request on the platform.
+ {"/paulca", "paulca", "paulca"},
+ {"/paulca/", "paulca", "paulca"},
+ {"/paulca/awesomeconf", "paulca", "paulca"},
+ {"/paulca/awesomeconf/admin", "paulca", "paulca"},
+ {"/paulca/awesomeconf/", "paulca", "paulca"},
+ {"/paulcarson", "paulcarson", "paulcarson"}, // segment equality, never HasPrefix
+ {"/", "", ""},
+ {"", "", ""},
+ {"paulca", "", ""}, // not an absolute path; never matches
+ {"//", "", ""},
+
+ // Leading empty segments and dot segments: only the CLEANED half
+ // finds the account. Rails and friends serve //paulca/foo as
+ // /paulca/foo, so without this half a flipped account falls through
+ // to the app it was flipped away from — the original I2 bypass.
+ {"//paulca", "", "paulca"},
+ {"//paulca/foo", "", "paulca"},
+ {"///paulca/foo", "", "paulca"},
+ {"/./paulca", ".", "paulca"},
+ {"/paulca/./awesomeconf", "paulca", "paulca"},
+ {"/paulca//awesomeconf", "paulca", "paulca"},
+
+ // ".." shapes: the two halves DISAGREE, and only the RAW half still
+ // names paulca. A backend that does not collapse ".." would serve
+ // paulca's page, so dropping the raw half here reopens the same
+ // bypass from the other side. Consulting both is what closes it.
+ {"/paulca/../bob", "paulca", "bob"},
+ {"/paulca/..", "paulca", ""},
+ {"/paulca/../", "paulca", ""},
+ {"/../bob", "..", "bob"}, // ".." can never be a prefix (validPathPrefix)
+ {"/..", "..", ""},
+ {"/.", ".", ""},
+ } {
+ if got := firstSegment(tc.in); got != tc.wantRaw {
+ t.Fatalf("firstSegment(%q) = %q, want %q", tc.in, got, tc.wantRaw)
+ }
+ if got := firstSegmentClean(tc.in); got != tc.wantClean {
+ t.Fatalf("firstSegmentClean(%q) = %q, want %q", tc.in, got, tc.wantClean)
+ }
+ }
+}
+
+// getHostPath issues GET path against srv with an explicit Host header.
+func getHostPath(t *testing.T, srvURL, host, path string) (int, string) {
+ t.Helper()
+ req, err := http.NewRequest("GET", srvURL+path, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Host = host
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ b, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return resp.StatusCode, string(b)
+}
diff --git a/internal/registry/paths_test.go b/internal/registry/paths_test.go
new file mode 100644
index 000000000..0e4c474db
--- /dev/null
+++ b/internal/registry/paths_test.go
@@ -0,0 +1,132 @@
+package registry
+
+import "testing"
+
+// TestPathRoundTrip: a written prefix row comes back, and a repoint of the
+// same (host, prefix) replaces rather than duplicates.
+func TestPathRoundTrip(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.UpsertPath(RoutePath{
+ Host: "ti.to", Prefix: "paulca", Target: "go.tito.io",
+ Account: "tito", App: "classic",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.ListPaths()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("ListPaths returned %d rows, want 1", len(got))
+ }
+ if got[0].Target != "go.tito.io" {
+ t.Fatalf("target = %q, want go.tito.io", got[0].Target)
+ }
+
+ if err := reg.UpsertPath(RoutePath{
+ Host: "ti.to", Prefix: "paulca", Target: "beta.tito.io",
+ Account: "tito", App: "classic",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ got, err = reg.ListPaths()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("repoint produced %d rows, want 1 — (host, prefix) is the key", len(got))
+ }
+ if got[0].Target != "beta.tito.io" {
+ t.Fatalf("target = %q, want beta.tito.io after repoint", got[0].Target)
+ }
+}
+
+// TestDeletePathIsIdempotent: deleting a row that is already gone is a
+// success, the same contract DeleteAlias carries. The reconcile pass deletes
+// unconditionally, so this must not error.
+func TestDeletePathIsIdempotent(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.DeletePath("ti.to", "nobody"); err != nil {
+ t.Fatalf("deleting an absent path row: %v", err)
+ }
+}
+
+// TestPathsAreScopedToTheirHost: two hosts may carry the same prefix without
+// colliding — the key is the pair, not the prefix.
+func TestPathsAreScopedToTheirHost(t *testing.T) {
+ reg := openTestRegistry(t)
+ for _, h := range []string{"ti.to", "other.example"} {
+ if err := reg.UpsertPath(RoutePath{
+ Host: h, Prefix: "paulca", Target: "go.tito.io",
+ Account: "tito", App: "classic",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := reg.ListPaths()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("got %d rows, want 2 — (host, prefix) is the key, not prefix alone", len(got))
+ }
+}
+
+// TestFingerprintChangesOnPathMutation is the load-bearing test of this
+// task. Fingerprint() is the 2s content hash the edge polls; a route_paths
+// change it cannot see is a flip that reconciles perfectly and never reaches
+// the live map — a silent false negative, the failure class Fingerprint's own
+// header names as the one to engineer against.
+func TestFingerprintChangesOnPathMutation(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.UpsertPath(RoutePath{
+ Host: "ti.to", Prefix: "paulca", Target: "go.tito.io",
+ Account: "tito", App: "classic",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ before, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.UpsertPath(RoutePath{
+ Host: "ti.to", Prefix: "paulca", Target: "beta.tito.io",
+ Account: "tito", App: "classic",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ after, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before == after {
+ t.Fatalf("fingerprint unchanged across a route_paths repoint: %q", after)
+ }
+}
+
+// TestFingerprintChangesOnPathRemoval: the un-flip direction. Rollback is a
+// row disappearing, and it must reach the edge on the same 2s bound as the
+// flip did.
+func TestFingerprintChangesOnPathRemoval(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.UpsertPath(RoutePath{
+ Host: "ti.to", Prefix: "paulca", Target: "go.tito.io",
+ Account: "tito", App: "classic",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ before, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.DeletePath("ti.to", "paulca"); err != nil {
+ t.Fatal(err)
+ }
+ after, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before == after {
+ t.Fatalf("fingerprint unchanged across a route_paths removal: %q", after)
+ }
+}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 130626a37..07baf89ce 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -415,6 +415,27 @@ CREATE TABLE IF NOT EXISTS wildcard_domains (
account TEXT NOT NULL DEFAULT '',
app TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT 0
+);`); err != nil {
+ return err
+ }
+ // route_paths is a derived table (the domains reconcile pass owns every
+ // row, materializing a claim's Paths map into it) — a plain CREATE TABLE
+ // IF NOT EXISTS, the same additive shape as route_aliases and
+ // wildcard_domains above, so existing DBs migrate on open with zero rows
+ // and no behaviour change.
+ //
+ // The key is the PAIR: `routes` has host as its primary key and must
+ // stay one row per hostname (that row owns the cert, the home box, the
+ // DNS state and the aliases), so a hostname's prefixes live here instead.
+ // prefix is a BARE segment — "paulca", never "/paulca".
+ if _, err := r.sql.Exec(`
+CREATE TABLE IF NOT EXISTS route_paths (
+ host TEXT NOT NULL,
+ prefix TEXT NOT NULL,
+ target TEXT NOT NULL,
+ account TEXT NOT NULL DEFAULT '',
+ app TEXT NOT NULL DEFAULT '',
+ PRIMARY KEY (host, prefix)
);`); err != nil {
return err
}
@@ -1594,8 +1615,69 @@ func (r *Registry) DeleteWildcardDomain(host string) error {
return err
}
-// Fingerprint summarizes the routes, fleets, fleet_boxes, route_aliases and
-// wildcard_domains tables over a fresh connection, for the edge's cheap
+// RoutePath is one derived row of the route_paths table: a leading URL path
+// segment on Host that is served by the route at Target instead of by Host's
+// own route. Wholly pass-owned, like route_aliases and wildcard_domains:
+// every row traces to a claim's Paths entry, so removal needs no ownership
+// flag.
+//
+// A path row deliberately carries NO upstream, channel or environment of its
+// own. It is a pointer; everything about how the traffic is served comes from
+// Target's ordinary route row. That is what makes pointing a subset of
+// prefixes at a second route a working canary rather than a special case.
+//
+// Prefix is a bare segment — "paulca", never "/paulca".
+type RoutePath struct {
+ Host string
+ Prefix string
+ Target string
+ Account string
+ App string
+}
+
+// ListPaths returns every path route, ordered by host then prefix.
+func (r *Registry) ListPaths() ([]RoutePath, error) {
+ rows, err := r.sql.Query(`SELECT host, prefix, target, account, app
+FROM route_paths ORDER BY host, prefix`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []RoutePath
+ for rows.Next() {
+ var p RoutePath
+ if err := rows.Scan(&p.Host, &p.Prefix, &p.Target, &p.Account, &p.App); err != nil {
+ return nil, err
+ }
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
+
+// UpsertPath inserts or repoints one path route by its (host, prefix) key.
+// Unlike UpsertAlias there are no stamp columns to preserve — nothing on this
+// table is maintained on a separate cadence — so every column is overwritten.
+func (r *Registry) UpsertPath(p RoutePath) error {
+ _, err := r.sql.Exec(`
+INSERT INTO route_paths (host, prefix, target, account, app)
+VALUES (?, ?, ?, ?, ?)
+ON CONFLICT(host, prefix) DO UPDATE SET
+ target=excluded.target, account=excluded.account, app=excluded.app`,
+ p.Host, p.Prefix, p.Target, p.Account, p.App)
+ return err
+}
+
+// DeletePath removes one path route. Idempotent: a row already gone is a
+// success, the same contract as DeleteAlias — the reconcile pass deletes
+// unconditionally from its own view of what is no longer desired.
+func (r *Registry) DeletePath(host, prefix string) error {
+ _, err := r.sql.Exec(`DELETE FROM route_paths WHERE host = ? AND prefix = ?`, host, prefix)
+ return err
+}
+
+// Fingerprint summarizes the routes, fleets, fleet_boxes, route_aliases,
+// wildcard_domains and route_paths tables over a fresh connection, for the
+// edge's cheap
// change poll. It is content-accurate: it hashes the actual VALUES of the
// reload-relevant columns, not a length-weighted aggregate, so two
// DIFFERENT values of the SAME length
@@ -1669,6 +1751,13 @@ func (r *Registry) DeleteWildcardDomain(host string) error {
// columns are hashed — there is no cadence-stamped column on this table to
// exclude, unlike route_aliases' dns_status/cert_expires_at.
//
+// route_paths is hashed for the same reload-latency reason, and it is the
+// section most worth stating explicitly: a path flip is the ONLY routing
+// change in the system whose whole observable effect is in this table. A
+// missing section here would not degrade a flip, it would make flips do
+// nothing at all — reconciled correctly, stored correctly, never served —
+// until some unrelated route write happened to move the digest.
+//
// The ORDER BY clauses are what make the digest deterministic across polls;
// a hypothetical reordering would only cost a redundant reload (idempotent),
// never a missed change — the failure mode worth engineering against is the
@@ -1792,6 +1881,30 @@ FROM fleet_boxes ORDER BY fleet, label`)
return "", err
}
+ // route_paths: which prefix on which hostname reaches which route is a
+ // routing decision in exactly the sense route_aliases' alias/host is, so
+ // it hashes here or a flip never reaches the live map. All five columns
+ // hash — unlike route_aliases there is no box-stamped, cadence-driven
+ // column on this table to exclude, so there is nothing here that could
+ // force a pointless fleet-wide reload.
+ fmt.Fprint(h, "\x1droute_paths\x1e")
+ prows, err := db.Query(`SELECT host, prefix, target, account, app
+FROM route_paths ORDER BY host, prefix`)
+ if err != nil {
+ return "", err
+ }
+ defer prows.Close()
+ for prows.Next() {
+ var host, prefix, target, account, app string
+ if err := prows.Scan(&host, &prefix, &target, &account, &app); err != nil {
+ return "", err
+ }
+ fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e", host, prefix, target, account, app)
+ }
+ if err := prows.Err(); err != nil {
+ return "", err
+ }
+
return fmt.Sprintf("%d:%x", n, h.Sum(nil)), nil
}
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..c4010c829 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,101 @@ 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"]])
+ }
+}
+
+// TestRoutesPathRowsAreNotCountedAsRoutes: a path row is a pointer at a
+// route, not a route. It is printed under its host, so when --match filters
+// every host away nothing is printed at all — and the "no routes" line must
+// still appear, because path rows must not inflate the count of what was
+// shown. The obvious spelling of this check (assert the line is ABSENT from
+// a listing that prints two routes) cannot fail, which is why it is written
+// the other way round.
+func TestRoutesPathRowsAreNotCountedAsRoutes(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"},
+ },
+ []map[string]any{
+ {"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,
+ "-match", "nothing-matches-this"})
+
+ if !strings.Contains(out.String(), "no routes for titogo") {
+ t.Fatalf("every route was filtered away, so the listing must say so:\n%s", out.String())
+ }
+ if strings.Contains(out.String(), "/paulca") {
+ t.Fatalf("a path row survived a filter its host did not:\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) {