docs: implementation plan for path-based routing
Six tasks, each with a failing test first and an independently landable commit: 1. route_paths table, accessors, fingerprint section 2. Claim.Paths and SetClaimPaths (wholesale, CAS, owner-gated) 3. Derive route_paths in the domains reconcile pass, same-account targets 4. Edge resolves a leading path segment; host-only lookup stays the primitive 5. The PUT write door and read-only surfaces 6. End-to-end flip and rollback Task 6 is the acceptance bar the spec asks for: with ti.to not moving behind the edge, nothing else proves the four layers compose rather than each working against a hand-written input.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+1920
−0
docs/superpowers/plans/2026-09-03-path-based-routing.md+1920 −0
CI passed — run details
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..57f37a922 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-03-path-based-routing.md |
| @@ -0,0 +1,1920 @@ |
| +# Path-Based Routing Implementation Plan |
| + |
| +> **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. |