| 1 | # Path-Based Routing Implementation Plan |
| 2 | |
| 3 | > **HISTORICAL ARTIFACT — superseded by the code that shipped.** This plan was |
| 4 | > written before implementation and is kept for the reasoning, not as a |
| 5 | > description of the system. Its code blocks are NOT what shipped: implementers |
| 6 | > found at least three defects in them (a missing serial bump on the write |
| 7 | > door, a validation ordering in `SetClaimPaths`, and a wire shape the door |
| 8 | > tightened to `{"paths": {...}}`). Read the design spec |
| 9 | > (`docs/superpowers/specs/2026-09-02-path-based-routing-design.md`) for intent |
| 10 | > and the source for behaviour; do not copy anything from here. |
| 11 | |
| 12 | > **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. |
| 13 | |
| 14 | **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. |
| 15 | |
| 16 | **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. |
| 17 | |
| 18 | **Tech Stack:** Go, SQLite (modernc `sqlite` driver), `net/http`, `net/http/httptest`. No new dependencies. |
| 19 | |
| 20 | **Spec:** `docs/superpowers/specs/2026-09-02-path-based-routing-design.md` |
| 21 | |
| 22 | ## Global Constraints |
| 23 | |
| 24 | - **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. |
| 25 | - **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. |
| 26 | - **No prefix stripping.** The upstream receives the original path unchanged. |
| 27 | - **`maxPathsPerClaim = 10000`.** Deterministic drops in sorted key order, logged, never silent. |
| 28 | - **Tito is the only writer.** All CARLOS-side surfaces are read-only. |
| 29 | - **CARLOS never learns Tito's account model.** No reserved-prefix list, no account validation, no URL-shape assumptions beyond "first segment". |
| 30 | - **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`. |
| 31 | - **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. |
| 32 | |
| 33 | --- |
| 34 | |
| 35 | ### Task 1: `route_paths` table, accessors, and the fingerprint section |
| 36 | |
| 37 | **Files:** |
| 38 | - Modify: `internal/registry/registry.go` (schema in `migrate()` ~line 395; new `RoutePath` type and accessors after `DeleteAlias` ~line 1533; `Fingerprint()` ~line 1676) |
| 39 | - Test: `internal/registry/paths_test.go` (create) |
| 40 | |
| 41 | **Interfaces:** |
| 42 | - Consumes: nothing (first task). |
| 43 | - 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`. |
| 44 | |
| 45 | - [ ] **Step 1: Write the failing tests** |
| 46 | |
| 47 | Create `internal/registry/paths_test.go`: |
| 48 | |
| 49 | ```go |
| 50 | package registry |
| 51 | |
| 52 | import "testing" |
| 53 | |
| 54 | // TestPathRoundTrip: a written prefix row comes back, and a repoint of the |
| 55 | // same (host, prefix) replaces rather than duplicates. |
| 56 | func TestPathRoundTrip(t *testing.T) { |
| 57 | reg := openTestRegistry(t) |
| 58 | if err := reg.UpsertPath(RoutePath{ |
| 59 | Host: "ti.to", Prefix: "paulca", Target: "go.tito.io", |
| 60 | Account: "tito", App: "classic", |
| 61 | }); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | got, err := reg.ListPaths() |
| 65 | if err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | if len(got) != 1 { |
| 69 | t.Fatalf("ListPaths returned %d rows, want 1", len(got)) |
| 70 | } |
| 71 | if got[0].Target != "go.tito.io" { |
| 72 | t.Fatalf("target = %q, want go.tito.io", got[0].Target) |
| 73 | } |
| 74 | |
| 75 | if err := reg.UpsertPath(RoutePath{ |
| 76 | Host: "ti.to", Prefix: "paulca", Target: "beta.tito.io", |
| 77 | Account: "tito", App: "classic", |
| 78 | }); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | got, err = reg.ListPaths() |
| 82 | if err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | if len(got) != 1 { |
| 86 | t.Fatalf("repoint produced %d rows, want 1 — (host, prefix) is the key", len(got)) |
| 87 | } |
| 88 | if got[0].Target != "beta.tito.io" { |
| 89 | t.Fatalf("target = %q, want beta.tito.io after repoint", got[0].Target) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // TestDeletePathIsIdempotent: deleting a row that is already gone is a |
| 94 | // success, the same contract DeleteAlias carries. The reconcile pass deletes |
| 95 | // unconditionally, so this must not error. |
| 96 | func TestDeletePathIsIdempotent(t *testing.T) { |
| 97 | reg := openTestRegistry(t) |
| 98 | if err := reg.DeletePath("ti.to", "nobody"); err != nil { |
| 99 | t.Fatalf("deleting an absent path row: %v", err) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // TestPathsAreScopedToTheirHost: two hosts may carry the same prefix without |
| 104 | // colliding — the key is the pair, not the prefix. |
| 105 | func TestPathsAreScopedToTheirHost(t *testing.T) { |
| 106 | reg := openTestRegistry(t) |
| 107 | for _, h := range []string{"ti.to", "other.example"} { |
| 108 | if err := reg.UpsertPath(RoutePath{ |
| 109 | Host: h, Prefix: "paulca", Target: "go.tito.io", |
| 110 | Account: "tito", App: "classic", |
| 111 | }); err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | } |
| 115 | got, err := reg.ListPaths() |
| 116 | if err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | if len(got) != 2 { |
| 120 | t.Fatalf("got %d rows, want 2 — (host, prefix) is the key, not prefix alone", len(got)) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | // TestFingerprintChangesOnPathMutation is the load-bearing test of this |
| 125 | // task. Fingerprint() is the 2s content hash the edge polls; a route_paths |
| 126 | // change it cannot see is a flip that reconciles perfectly and never reaches |
| 127 | // the live map — a silent false negative, the failure class Fingerprint's own |
| 128 | // header names as the one to engineer against. |
| 129 | func TestFingerprintChangesOnPathMutation(t *testing.T) { |
| 130 | reg := openTestRegistry(t) |
| 131 | if err := reg.UpsertPath(RoutePath{ |
| 132 | Host: "ti.to", Prefix: "paulca", Target: "go.tito.io", |
| 133 | Account: "tito", App: "classic", |
| 134 | }); err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | before, err := reg.Fingerprint() |
| 138 | if err != nil { |
| 139 | t.Fatal(err) |
| 140 | } |
| 141 | if err := reg.UpsertPath(RoutePath{ |
| 142 | Host: "ti.to", Prefix: "paulca", Target: "beta.tito.io", |
| 143 | Account: "tito", App: "classic", |
| 144 | }); err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | after, err := reg.Fingerprint() |
| 148 | if err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | if before == after { |
| 152 | t.Fatalf("fingerprint unchanged across a route_paths repoint: %q", after) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | // TestFingerprintChangesOnPathRemoval: the un-flip direction. Rollback is a |
| 157 | // row disappearing, and it must reach the edge on the same 2s bound as the |
| 158 | // flip did. |
| 159 | func TestFingerprintChangesOnPathRemoval(t *testing.T) { |
| 160 | reg := openTestRegistry(t) |
| 161 | if err := reg.UpsertPath(RoutePath{ |
| 162 | Host: "ti.to", Prefix: "paulca", Target: "go.tito.io", |
| 163 | Account: "tito", App: "classic", |
| 164 | }); err != nil { |
| 165 | t.Fatal(err) |
| 166 | } |
| 167 | before, err := reg.Fingerprint() |
| 168 | if err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | if err := reg.DeletePath("ti.to", "paulca"); err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | after, err := reg.Fingerprint() |
| 175 | if err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | if before == after { |
| 179 | t.Fatalf("fingerprint unchanged across a route_paths removal: %q", after) |
| 180 | } |
| 181 | } |
| 182 | ``` |
| 183 | |
| 184 | - [ ] **Step 2: Run the tests to verify they fail** |
| 185 | |
| 186 | Run: `go test ./internal/registry/ -run 'TestPath|TestDeletePath|TestFingerprintChangesOnPath' -count=1` |
| 187 | Expected: FAIL — `undefined: RoutePath`, `reg.UpsertPath undefined`. |
| 188 | |
| 189 | - [ ] **Step 3: Add the table to `migrate()`** |
| 190 | |
| 191 | In `internal/registry/registry.go`, immediately after the `wildcard_domains` `CREATE TABLE IF NOT EXISTS` block: |
| 192 | |
| 193 | ```go |
| 194 | // route_paths is a derived table (the domains reconcile pass owns every |
| 195 | // row, materializing a claim's Paths map into it) — a plain CREATE TABLE |
| 196 | // IF NOT EXISTS, the same additive shape as route_aliases and |
| 197 | // wildcard_domains above, so existing DBs migrate on open with zero rows |
| 198 | // and no behaviour change. |
| 199 | // |
| 200 | // The key is the PAIR: `routes` has host as its primary key and must |
| 201 | // stay one row per hostname (that row owns the cert, the home box, the |
| 202 | // DNS state and the aliases), so a hostname's prefixes live here instead. |
| 203 | // prefix is a BARE segment — "paulca", never "/paulca". |
| 204 | if _, err := r.sql.Exec(` |
| 205 | CREATE TABLE IF NOT EXISTS route_paths ( |
| 206 | host TEXT NOT NULL, |
| 207 | prefix TEXT NOT NULL, |
| 208 | target TEXT NOT NULL, |
| 209 | account TEXT NOT NULL DEFAULT '', |
| 210 | app TEXT NOT NULL DEFAULT '', |
| 211 | PRIMARY KEY (host, prefix) |
| 212 | );`); err != nil { |
| 213 | return err |
| 214 | } |
| 215 | ``` |
| 216 | |
| 217 | - [ ] **Step 4: Add the type and accessors** |
| 218 | |
| 219 | In `internal/registry/registry.go`, after `DeleteAlias` (~line 1533): |
| 220 | |
| 221 | ```go |
| 222 | // RoutePath is one derived row of the route_paths table: a leading URL path |
| 223 | // segment on Host that is served by the route at Target instead of by Host's |
| 224 | // own route. Wholly pass-owned, like route_aliases and wildcard_domains: |
| 225 | // every row traces to a claim's Paths entry, so removal needs no ownership |
| 226 | // flag. |
| 227 | // |
| 228 | // A path row deliberately carries NO upstream, channel or environment of its |
| 229 | // own. It is a pointer; everything about how the traffic is served comes from |
| 230 | // Target's ordinary route row. That is what makes pointing a subset of |
| 231 | // prefixes at a second route a working canary rather than a special case. |
| 232 | // |
| 233 | // Prefix is a bare segment — "paulca", never "/paulca". |
| 234 | type RoutePath struct { |
| 235 | Host string |
| 236 | Prefix string |
| 237 | Target string |
| 238 | Account string |
| 239 | App string |
| 240 | } |
| 241 | |
| 242 | // ListPaths returns every path route, ordered by host then prefix. |
| 243 | func (r *Registry) ListPaths() ([]RoutePath, error) { |
| 244 | rows, err := r.sql.Query(`SELECT host, prefix, target, account, app |
| 245 | FROM route_paths ORDER BY host, prefix`) |
| 246 | if err != nil { |
| 247 | return nil, err |
| 248 | } |
| 249 | defer rows.Close() |
| 250 | var out []RoutePath |
| 251 | for rows.Next() { |
| 252 | var p RoutePath |
| 253 | if err := rows.Scan(&p.Host, &p.Prefix, &p.Target, &p.Account, &p.App); err != nil { |
| 254 | return nil, err |
| 255 | } |
| 256 | out = append(out, p) |
| 257 | } |
| 258 | return out, rows.Err() |
| 259 | } |
| 260 | |
| 261 | // UpsertPath inserts or repoints one path route by its (host, prefix) key. |
| 262 | // Unlike UpsertAlias there are no stamp columns to preserve — nothing on this |
| 263 | // table is maintained on a separate cadence — so every column is overwritten. |
| 264 | func (r *Registry) UpsertPath(p RoutePath) error { |
| 265 | _, err := r.sql.Exec(` |
| 266 | INSERT INTO route_paths (host, prefix, target, account, app) |
| 267 | VALUES (?, ?, ?, ?, ?) |
| 268 | ON CONFLICT(host, prefix) DO UPDATE SET |
| 269 | target=excluded.target, account=excluded.account, app=excluded.app`, |
| 270 | p.Host, p.Prefix, p.Target, p.Account, p.App) |
| 271 | return err |
| 272 | } |
| 273 | |
| 274 | // DeletePath removes one path route. Idempotent: a row already gone is a |
| 275 | // success, the same contract as DeleteAlias — the reconcile pass deletes |
| 276 | // unconditionally from its own view of what is no longer desired. |
| 277 | func (r *Registry) DeletePath(host, prefix string) error { |
| 278 | _, err := r.sql.Exec(`DELETE FROM route_paths WHERE host = ? AND prefix = ?`, host, prefix) |
| 279 | return err |
| 280 | } |
| 281 | ``` |
| 282 | |
| 283 | - [ ] **Step 5: Add the fingerprint section** |
| 284 | |
| 285 | In `Fingerprint()`, after the `wildcard_domains` section and before the final digest is returned: |
| 286 | |
| 287 | ```go |
| 288 | // route_paths: which prefix on which hostname reaches which route is a |
| 289 | // routing decision in exactly the sense route_aliases' alias/host is, so |
| 290 | // it hashes here or a flip never reaches the live map. All five columns |
| 291 | // hash — unlike route_aliases there is no box-stamped, cadence-driven |
| 292 | // column on this table to exclude, so there is nothing here that could |
| 293 | // force a pointless fleet-wide reload. |
| 294 | fmt.Fprint(h, "\x1droute_paths\x1e") |
| 295 | prows, err := db.Query(`SELECT host, prefix, target, account, app |
| 296 | FROM route_paths ORDER BY host, prefix`) |
| 297 | if err != nil { |
| 298 | return "", err |
| 299 | } |
| 300 | defer prows.Close() |
| 301 | for prows.Next() { |
| 302 | var host, prefix, target, account, app string |
| 303 | if err := prows.Scan(&host, &prefix, &target, &account, &app); err != nil { |
| 304 | return "", err |
| 305 | } |
| 306 | fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e", host, prefix, target, account, app) |
| 307 | } |
| 308 | if err := prows.Err(); err != nil { |
| 309 | return "", err |
| 310 | } |
| 311 | ``` |
| 312 | |
| 313 | Also extend the `Fingerprint` doc comment, after the `wildcard_domains` paragraph: |
| 314 | |
| 315 | ```go |
| 316 | // route_paths is hashed for the same reload-latency reason, and it is the |
| 317 | // section most worth stating explicitly: a path flip is the ONLY routing |
| 318 | // change in the system whose whole observable effect is in this table. A |
| 319 | // missing section here would not degrade a flip, it would make flips do |
| 320 | // nothing at all — reconciled correctly, stored correctly, never served — |
| 321 | // until some unrelated route write happened to move the digest. |
| 322 | ``` |
| 323 | |
| 324 | - [ ] **Step 6: Run the tests to verify they pass** |
| 325 | |
| 326 | Run: `go test ./internal/registry/ -count=1` |
| 327 | Expected: PASS, including the four new tests and every existing registry test. |
| 328 | |
| 329 | - [ ] **Step 7: Verify the whole build is clean** |
| 330 | |
| 331 | Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1` |
| 332 | Expected: no output from `gofmt -l .`, all tests pass. Zero `route_paths` rows exist anywhere, so nothing else can have changed behaviour. |
| 333 | |
| 334 | - [ ] **Step 8: Commit** |
| 335 | |
| 336 | ```bash |
| 337 | git add internal/registry/registry.go internal/registry/paths_test.go |
| 338 | git commit -m "registry: route_paths table, accessors, and fingerprint section |
| 339 | |
| 340 | A hostname's routes table row is its identity — cert, home box, DNS state, |
| 341 | aliases — so per-prefix routing lives in its own pass-owned table keyed on |
| 342 | (host, prefix), mirroring route_aliases. |
| 343 | |
| 344 | The fingerprint section is the load-bearing half: a path flip's entire |
| 345 | observable effect is in this table, so without it flips would reconcile |
| 346 | correctly and never be served. |
| 347 | |
| 348 | Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| 349 | ``` |
| 350 | |
| 351 | --- |
| 352 | |
| 353 | ### Task 2: `Claim.Paths` and `SetClaimPaths` |
| 354 | |
| 355 | **Files:** |
| 356 | - Modify: `internal/domains/claims.go` (`Claim` struct ~line 34; new `SetClaimPaths` after `SetClaimCapabilities` ~line 317) |
| 357 | - Test: `internal/domains/paths_test.go` (create) |
| 358 | |
| 359 | **Interfaces:** |
| 360 | - Consumes: nothing from Task 1 (independent; may be built in parallel). |
| 361 | - 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`. |
| 362 | |
| 363 | - [ ] **Step 1: Write the failing tests** |
| 364 | |
| 365 | 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. |
| 366 | |
| 367 | ```go |
| 368 | package domains |
| 369 | |
| 370 | import ( |
| 371 | "context" |
| 372 | "errors" |
| 373 | "strconv" |
| 374 | "testing" |
| 375 | ) |
| 376 | |
| 377 | // TestSetClaimPathsRoundTrip: the map written is the map read back. |
| 378 | func TestSetClaimPathsRoundTrip(t *testing.T) { |
| 379 | ctx := context.Background() |
| 380 | s := newTestStore(t) // see claims_test.go for the existing fake |
| 381 | if _, _, err := AttachClaim(ctx, s, Claim{ |
| 382 | Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| 383 | Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| 384 | }); err != nil { |
| 385 | t.Fatal(err) |
| 386 | } |
| 387 | changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| 388 | map[string]string{"paulca": "go.tito.io"}) |
| 389 | if err != nil { |
| 390 | t.Fatal(err) |
| 391 | } |
| 392 | if !changed { |
| 393 | t.Fatal("changed = false on the first write of a paths map") |
| 394 | } |
| 395 | c, err := GetClaim(ctx, s, "ti.to") |
| 396 | if err != nil { |
| 397 | t.Fatal(err) |
| 398 | } |
| 399 | if c.Paths["paulca"] != "go.tito.io" { |
| 400 | t.Fatalf("paths = %v, want paulca -> go.tito.io", c.Paths) |
| 401 | } |
| 402 | } |
| 403 | |
| 404 | // TestSetClaimPathsIsWholesale: publishing is a REPLACE, not a merge. Tito |
| 405 | // regenerates the whole map from its own database, so an account it no longer |
| 406 | // lists must disappear — that is how a rollback reaches the edge. |
| 407 | func TestSetClaimPathsIsWholesale(t *testing.T) { |
| 408 | ctx := context.Background() |
| 409 | s := newTestStore(t) |
| 410 | if _, _, err := AttachClaim(ctx, s, Claim{ |
| 411 | Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| 412 | Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| 413 | }); err != nil { |
| 414 | t.Fatal(err) |
| 415 | } |
| 416 | if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| 417 | map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"}); err != nil { |
| 418 | t.Fatal(err) |
| 419 | } |
| 420 | if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| 421 | map[string]string{"paulca": "go.tito.io"}); err != nil { |
| 422 | t.Fatal(err) |
| 423 | } |
| 424 | c, err := GetClaim(ctx, s, "ti.to") |
| 425 | if err != nil { |
| 426 | t.Fatal(err) |
| 427 | } |
| 428 | if _, still := c.Paths["bigcorp"]; still { |
| 429 | t.Fatal("bigcorp survived a republish that omitted it — publishing must replace, not merge") |
| 430 | } |
| 431 | } |
| 432 | |
| 433 | // TestSetClaimPathsEmptyClearsTheMap: the final cutover. When every account |
| 434 | // is across, Tito repoints the host's own route and publishes an empty map. |
| 435 | func TestSetClaimPathsEmptyClearsTheMap(t *testing.T) { |
| 436 | ctx := context.Background() |
| 437 | s := newTestStore(t) |
| 438 | if _, _, err := AttachClaim(ctx, s, Claim{ |
| 439 | Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| 440 | Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| 441 | }); err != nil { |
| 442 | t.Fatal(err) |
| 443 | } |
| 444 | if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| 445 | map[string]string{"paulca": "go.tito.io"}); err != nil { |
| 446 | t.Fatal(err) |
| 447 | } |
| 448 | if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", nil); err != nil { |
| 449 | t.Fatal(err) |
| 450 | } |
| 451 | c, err := GetClaim(ctx, s, "ti.to") |
| 452 | if err != nil { |
| 453 | t.Fatal(err) |
| 454 | } |
| 455 | if len(c.Paths) != 0 { |
| 456 | t.Fatalf("paths = %v, want empty after publishing an empty map", c.Paths) |
| 457 | } |
| 458 | } |
| 459 | |
| 460 | // TestSetClaimPathsIsOwnerGated: a caller who does not hold the claim gets |
| 461 | // the same neutral ErrHostUnavailable the attach door uses — no existence |
| 462 | // oracle, byte-identical to the answer for a claim that does not exist. |
| 463 | func TestSetClaimPathsIsOwnerGated(t *testing.T) { |
| 464 | ctx := context.Background() |
| 465 | s := newTestStore(t) |
| 466 | if _, _, err := AttachClaim(ctx, s, Claim{ |
| 467 | Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| 468 | Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| 469 | }); err != nil { |
| 470 | t.Fatal(err) |
| 471 | } |
| 472 | if _, err := SetClaimPaths(ctx, s, "ti.to", "someone-else", "classic", |
| 473 | map[string]string{"paulca": "evil.example"}); !errors.Is(err, ErrHostUnavailable) { |
| 474 | t.Fatalf("err = %v, want ErrHostUnavailable for a non-owner", err) |
| 475 | } |
| 476 | if _, err := SetClaimPaths(ctx, s, "never-claimed.example", "tito", "classic", |
| 477 | map[string]string{"a": "b"}); !errors.Is(err, ErrHostUnavailable) { |
| 478 | t.Fatalf("err = %v, want the identical ErrHostUnavailable for an absent claim", err) |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | // TestSetClaimPathsUnchangedIsNoWrite: republishing an identical map must not |
| 483 | // burn a bucket write. Tito republishes wholesale as a repair, so the no-op |
| 484 | // case is the common one. |
| 485 | func TestSetClaimPathsUnchangedIsNoWrite(t *testing.T) { |
| 486 | ctx := context.Background() |
| 487 | s := newTestStore(t) |
| 488 | if _, _, err := AttachClaim(ctx, s, Claim{ |
| 489 | Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| 490 | Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| 491 | }); err != nil { |
| 492 | t.Fatal(err) |
| 493 | } |
| 494 | m := map[string]string{"paulca": "go.tito.io"} |
| 495 | if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m); err != nil { |
| 496 | t.Fatal(err) |
| 497 | } |
| 498 | changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m) |
| 499 | if err != nil { |
| 500 | t.Fatal(err) |
| 501 | } |
| 502 | if changed { |
| 503 | t.Fatal("changed = true for an identical republish — a no-op must not write") |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | // TestSetClaimPathsRefusesOversizeMap: the cap is enforced at the door, so an |
| 508 | // oversize map is refused whole rather than silently truncated into the |
| 509 | // bucket. (The reconcile pass caps again, defensively, for hand-written |
| 510 | // claims — see Task 3.) |
| 511 | func TestSetClaimPathsRefusesOversizeMap(t *testing.T) { |
| 512 | ctx := context.Background() |
| 513 | s := newTestStore(t) |
| 514 | if _, _, err := AttachClaim(ctx, s, Claim{ |
| 515 | Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| 516 | Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| 517 | }); err != nil { |
| 518 | t.Fatal(err) |
| 519 | } |
| 520 | big := make(map[string]string, MaxPathsPerClaim+1) |
| 521 | for i := 0; i <= MaxPathsPerClaim; i++ { |
| 522 | big["acct"+strconv.Itoa(i)] = "go.tito.io" |
| 523 | } |
| 524 | if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", big); !errors.Is(err, ErrTooManyPaths) { |
| 525 | t.Fatalf("err = %v, want ErrTooManyPaths", err) |
| 526 | } |
| 527 | } |
| 528 | |
| 529 | // TestSetClaimPathsRejectsMalformedPrefixes: a prefix is a bare segment. A |
| 530 | // leading slash, an embedded slash, or an empty key would each produce a row |
| 531 | // the edge could never match, so they are refused at the door rather than |
| 532 | // stored as dead weight. |
| 533 | func TestSetClaimPathsRejectsMalformedPrefixes(t *testing.T) { |
| 534 | ctx := context.Background() |
| 535 | s := newTestStore(t) |
| 536 | if _, _, err := AttachClaim(ctx, s, Claim{ |
| 537 | Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| 538 | Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| 539 | }); err != nil { |
| 540 | t.Fatal(err) |
| 541 | } |
| 542 | for _, bad := range []string{"/paulca", "paulca/awesomeconf", "", "with space"} { |
| 543 | if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| 544 | map[string]string{bad: "go.tito.io"}); !errors.Is(err, ErrBadPathPrefix) { |
| 545 | t.Fatalf("prefix %q: err = %v, want ErrBadPathPrefix", bad, err) |
| 546 | } |
| 547 | } |
| 548 | } |
| 549 | ``` |
| 550 | |
| 551 | - [ ] **Step 2: Run the tests to verify they fail** |
| 552 | |
| 553 | Run: `go test ./internal/domains/ -run TestSetClaimPaths -count=1` |
| 554 | Expected: FAIL — `undefined: SetClaimPaths`, `undefined: MaxPathsPerClaim`. |
| 555 | |
| 556 | - [ ] **Step 3: Add the `Paths` field to `Claim`** |
| 557 | |
| 558 | In `internal/domains/claims.go`, inside the `Claim` struct, after `Catchall`: |
| 559 | |
| 560 | ```go |
| 561 | // Paths splits this hostname's traffic by leading URL segment: a bare |
| 562 | // first segment ("paulca", never "/paulca") mapped to the HOSTNAME of the |
| 563 | // route that serves it. Absent or empty means this host routes by |
| 564 | // hostname alone — the behaviour of every host today, and the reason this |
| 565 | // field is additive in the strongest sense: no row, no map, no change. |
| 566 | // |
| 567 | // This is a THIRD claim capability alongside Wildcard/Catchall, and it is |
| 568 | // declared here for the reason stated above them — converge preserves only |
| 569 | // DECLARED fields, so an undeclared field would be stripped by any binary |
| 570 | // that rewrote the claim. |
| 571 | // |
| 572 | // The writer is the tenant's own platform, publishing the whole map |
| 573 | // regenerated from its own database. Deltas were rejected deliberately: a |
| 574 | // lost delta desyncs the two systems silently, whereas a wholesale |
| 575 | // republish is always a full repair. |
| 576 | Paths map[string]string `json:"paths,omitempty"` |
| 577 | ``` |
| 578 | |
| 579 | - [ ] **Step 4: Add the errors, the cap, and `SetClaimPaths`** |
| 580 | |
| 581 | In `internal/domains/claims.go`, after `SetClaimCapabilities`: |
| 582 | |
| 583 | ```go |
| 584 | // MaxPathsPerClaim bounds a claim's prefix map. Unlike maxWildcardDomains |
| 585 | // this defends no external budget — there is no ACME equivalent for a path |
| 586 | // row — it bounds the claim object's size and the per-box fold that reads it |
| 587 | // every fast-converge tick. At roughly 40 bytes an entry this is a ~400KB |
| 588 | // object refolded every couple of seconds, which is comfortable; sharding a |
| 589 | // host's map across objects is deliberately not built until a real host |
| 590 | // approaches this number. |
| 591 | const MaxPathsPerClaim = 10000 |
| 592 | |
| 593 | // ErrTooManyPaths is returned by SetClaimPaths for a map above |
| 594 | // MaxPathsPerClaim. Refused whole: a silently truncated map would route a |
| 595 | // subset of accounts and look successful. |
| 596 | var ErrTooManyPaths = errors.New("too many path prefixes") |
| 597 | |
| 598 | // ErrBadPathPrefix is returned by SetClaimPaths for a key that is not a bare |
| 599 | // single path segment. |
| 600 | var ErrBadPathPrefix = errors.New("path prefix must be a single bare segment") |
| 601 | |
| 602 | // validPathPrefix reports whether p is a bare first path segment: non-empty, |
| 603 | // no slashes, and nothing that would not survive a URL path intact. The edge |
| 604 | // matches the request's first segment by EQUALITY, so anything that cannot be |
| 605 | // a first segment could never match and must not be stored. |
| 606 | func validPathPrefix(p string) bool { |
| 607 | if p == "" || len(p) > 255 { |
| 608 | return false |
| 609 | } |
| 610 | for i := 0; i < len(p); i++ { |
| 611 | c := p[i] |
| 612 | switch { |
| 613 | case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': |
| 614 | case c == '-' || c == '_' || c == '.' || c == '~': |
| 615 | default: |
| 616 | return false |
| 617 | } |
| 618 | } |
| 619 | // "." and ".." would be normalised away by any path cleaner between the |
| 620 | // browser and here, so a row keyed on either is unreachable by |
| 621 | // construction. |
| 622 | return p != "." && p != ".." |
| 623 | } |
| 624 | |
| 625 | // SetClaimPaths is the ONLY writer of a claim's Paths field, the same |
| 626 | // single-writer discipline SetClaimCapabilities holds over Wildcard/Catchall |
| 627 | // and for the same reason: AttachClaim's converge repoints Route and nothing |
| 628 | // else, so it can preserve this map but must never invent or strip it. |
| 629 | // |
| 630 | // The write is WHOLESALE — paths replaces the stored map rather than merging |
| 631 | // into it. That is the contract the caller's platform relies on: it |
| 632 | // regenerates the map from its own database and publishes the result, so an |
| 633 | // entry it no longer lists must disappear. Merging would make rollback |
| 634 | // impossible to express. |
| 635 | // |
| 636 | // Owner-gated exactly as SetClaimCapabilities is: a caller who does not hold |
| 637 | // the claim gets the neutral ErrHostUnavailable, and a missing claim answers |
| 638 | // identically, so this door is not an existence oracle. |
| 639 | func SetClaimPaths(ctx context.Context, s release.Store, host, account, app string, paths map[string]string) (changed bool, err error) { |
| 640 | if len(paths) > MaxPathsPerClaim { |
| 641 | return false, ErrTooManyPaths |
| 642 | } |
| 643 | for prefix, target := range paths { |
| 644 | if !validPathPrefix(prefix) { |
| 645 | return false, ErrBadPathPrefix |
| 646 | } |
| 647 | // The target must be a plausible hostname. Whether it is a route this |
| 648 | // account owns is decided by the reconcile pass, which — unlike this |
| 649 | // door — can see the registry. |
| 650 | if n, ok := NormalizeAliasHost(target); !ok || n != target { |
| 651 | return false, fmt.Errorf("domains: path prefix %q: target %q is not a normalised hostname", prefix, target) |
| 652 | } |
| 653 | } |
| 654 | for attempt := 0; ; attempt++ { |
| 655 | cur, etag, err := getClaimETag(ctx, s, host) |
| 656 | if errors.Is(err, os.ErrNotExist) { |
| 657 | return false, ErrHostUnavailable |
| 658 | } else if err != nil { |
| 659 | return false, err |
| 660 | } |
| 661 | if cur.Account != account || cur.App != app { |
| 662 | return false, ErrHostUnavailable |
| 663 | } |
| 664 | if samePaths(cur.Paths, paths) { |
| 665 | return false, nil |
| 666 | } |
| 667 | next := *cur |
| 668 | if len(paths) == 0 { |
| 669 | next.Paths = nil // omitempty: an empty map and no map are one state |
| 670 | } else { |
| 671 | next.Paths = make(map[string]string, len(paths)) |
| 672 | for k, v := range paths { |
| 673 | next.Paths[k] = v |
| 674 | } |
| 675 | } |
| 676 | body, err := json.Marshal(next) |
| 677 | if err != nil { |
| 678 | return false, err |
| 679 | } |
| 680 | if _, err := s.Put(ctx, ClaimKey(host), body, etag); err == nil { |
| 681 | return true, nil |
| 682 | } else if errors.Is(err, release.ErrCASConflict) && attempt < 3 { |
| 683 | continue |
| 684 | } else { |
| 685 | return false, err |
| 686 | } |
| 687 | } |
| 688 | } |
| 689 | |
| 690 | // samePaths reports whether two prefix maps are equal, treating nil and empty |
| 691 | // as the same state. Republishing an unchanged map is the COMMON case — the |
| 692 | // caller republishes wholesale as a repair — so this is what keeps a repair |
| 693 | // from costing a bucket write and a fleet-wide edge reload. |
| 694 | func samePaths(a, b map[string]string) bool { |
| 695 | if len(a) != len(b) { |
| 696 | return false |
| 697 | } |
| 698 | for k, v := range a { |
| 699 | if b[k] != v { |
| 700 | return false |
| 701 | } |
| 702 | } |
| 703 | return true |
| 704 | } |
| 705 | ``` |
| 706 | |
| 707 | Confirm `fmt` is imported in `claims.go` (it is — `getClaimETag` uses it). |
| 708 | |
| 709 | - [ ] **Step 5: Run the tests to verify they pass** |
| 710 | |
| 711 | Run: `go test ./internal/domains/ -count=1` |
| 712 | Expected: PASS. |
| 713 | |
| 714 | - [ ] **Step 6: Verify converge round-trips the new field** |
| 715 | |
| 716 | `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: |
| 717 | |
| 718 | ```go |
| 719 | // TestAttachConvergePreservesPaths: a repoint must not strip the prefix map. |
| 720 | func TestAttachConvergePreservesPaths(t *testing.T) { |
| 721 | ctx := context.Background() |
| 722 | s := newTestStore(t) |
| 723 | if _, _, err := AttachClaim(ctx, s, Claim{ |
| 724 | Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| 725 | Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| 726 | }); err != nil { |
| 727 | t.Fatal(err) |
| 728 | } |
| 729 | if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| 730 | map[string]string{"paulca": "go.tito.io"}); err != nil { |
| 731 | t.Fatal(err) |
| 732 | } |
| 733 | if _, _, err := AttachClaim(ctx, s, Claim{ |
| 734 | Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| 735 | Route: "ti-to-v2.tito.io", CreatedAt: "2026-09-03T00:00:00Z", |
| 736 | }); err != nil { |
| 737 | t.Fatal(err) |
| 738 | } |
| 739 | c, err := GetClaim(ctx, s, "ti.to") |
| 740 | if err != nil { |
| 741 | t.Fatal(err) |
| 742 | } |
| 743 | if c.Paths["paulca"] != "go.tito.io" { |
| 744 | t.Fatalf("paths = %v, want the map preserved across a converge repoint", c.Paths) |
| 745 | } |
| 746 | } |
| 747 | ``` |
| 748 | |
| 749 | Run: `go test ./internal/domains/ -count=1` — PASS. |
| 750 | |
| 751 | - [ ] **Step 7: Commit** |
| 752 | |
| 753 | ```bash |
| 754 | git add internal/domains/claims.go internal/domains/paths_test.go |
| 755 | git commit -m "domains: claims carry a prefix map, written only by SetClaimPaths |
| 756 | |
| 757 | domains/<host>.json is already one CAS-written object per hostname carrying |
| 758 | account and app — exactly the granularity and write semantics a per-host |
| 759 | prefix map needs, so path routing is a third claim capability rather than a |
| 760 | new control-plane object type. |
| 761 | |
| 762 | Writes are wholesale: the tenant's platform regenerates the map from its own |
| 763 | database and publishes the result, so a republish is always a full repair and |
| 764 | a lost update can never desync the two systems. |
| 765 | |
| 766 | Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| 767 | ``` |
| 768 | |
| 769 | --- |
| 770 | |
| 771 | ### Task 3: Derive `route_paths` in the reconcile pass |
| 772 | |
| 773 | **Files:** |
| 774 | - Modify: `internal/domains/aliases.go` (`ReconcileAliases`, line 48; the desired-set loop and the pass-owned deletion tail ~line 190-222) |
| 775 | - Test: `internal/domains/aliases_paths_test.go` (create) |
| 776 | |
| 777 | **Interfaces:** |
| 778 | - Consumes: `registry.RoutePath`, `(*Registry).ListPaths/UpsertPath/DeletePath` (Task 1); `Claim.Paths`, `MaxPathsPerClaim` (Task 2). |
| 779 | - Produces: no new exported symbols. `route_paths` rows now exist in the registry for Task 4 to read. |
| 780 | |
| 781 | - [ ] **Step 1: Write the failing tests** |
| 782 | |
| 783 | 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. |
| 784 | |
| 785 | ```go |
| 786 | package domains |
| 787 | |
| 788 | import ( |
| 789 | "context" |
| 790 | "strconv" |
| 791 | "testing" |
| 792 | "time" |
| 793 | |
| 794 | "github.com/carlosframework/platform/internal/registry" |
| 795 | ) |
| 796 | |
| 797 | // pathsByPrefix collapses ListPaths into a prefix -> target map for one host. |
| 798 | func pathsByPrefix(t *testing.T, reg *registry.Registry, host string) map[string]string { |
| 799 | t.Helper() |
| 800 | rows, err := reg.ListPaths() |
| 801 | if err != nil { |
| 802 | t.Fatal(err) |
| 803 | } |
| 804 | out := map[string]string{} |
| 805 | for _, p := range rows { |
| 806 | if p.Host == host { |
| 807 | out[p.Prefix] = p.Target |
| 808 | } |
| 809 | } |
| 810 | return out |
| 811 | } |
| 812 | |
| 813 | // TestReconcileDerivesPathRows is the feature: a claim's Paths map becomes |
| 814 | // route_paths rows. |
| 815 | func TestReconcileDerivesPathRows(t *testing.T) { |
| 816 | ctx := context.Background() |
| 817 | s := newTestStore(t) |
| 818 | reg := newTestRegistry(t) |
| 819 | mustRoute(t, reg, "ti.to", "tito", "classic") |
| 820 | mustRoute(t, reg, "go.tito.io", "tito", "classic") |
| 821 | mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to", |
| 822 | map[string]string{"paulca": "go.tito.io"}) |
| 823 | |
| 824 | if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil { |
| 825 | t.Fatal(err) |
| 826 | } |
| 827 | if got := pathsByPrefix(t, reg, "ti.to"); got["paulca"] != "go.tito.io" { |
| 828 | t.Fatalf("route_paths = %v, want paulca -> go.tito.io", got) |
| 829 | } |
| 830 | } |
| 831 | |
| 832 | // TestReconcileRemovesUndesiredPathRows is the rollback path and the |
| 833 | // pass-ownership property: a prefix dropped from the published map disappears |
| 834 | // from the table on the next pass, with no delete verb anywhere. |
| 835 | func TestReconcileRemovesUndesiredPathRows(t *testing.T) { |
| 836 | ctx := context.Background() |
| 837 | s := newTestStore(t) |
| 838 | reg := newTestRegistry(t) |
| 839 | mustRoute(t, reg, "ti.to", "tito", "classic") |
| 840 | mustRoute(t, reg, "go.tito.io", "tito", "classic") |
| 841 | mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to", |
| 842 | map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"}) |
| 843 | if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil { |
| 844 | t.Fatal(err) |
| 845 | } |
| 846 | if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| 847 | map[string]string{"paulca": "go.tito.io"}); err != nil { |
| 848 | t.Fatal(err) |
| 849 | } |
| 850 | if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil { |
| 851 | t.Fatal(err) |
| 852 | } |
| 853 | got := pathsByPrefix(t, reg, "ti.to") |
| 854 | if _, still := got["bigcorp"]; still { |
| 855 | t.Fatalf("route_paths = %v, want bigcorp gone after it left the published map", got) |
| 856 | } |
| 857 | if got["paulca"] != "go.tito.io" { |
| 858 | t.Fatalf("route_paths = %v, want paulca still routed", got) |
| 859 | } |
| 860 | } |
| 861 | |
| 862 | // TestReconcileRefusesCrossTenantTarget is the one genuinely new authorization |
| 863 | // rule in this feature: a prefix must never point another tenant's traffic at |
| 864 | // your backend. |
| 865 | func TestReconcileRefusesCrossTenantTarget(t *testing.T) { |
| 866 | ctx := context.Background() |
| 867 | s := newTestStore(t) |
| 868 | reg := newTestRegistry(t) |
| 869 | mustRoute(t, reg, "ti.to", "tito", "classic") |
| 870 | mustRoute(t, reg, "victim.example", "someone-else", "their-app") |
| 871 | mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to", |
| 872 | map[string]string{"paulca": "victim.example"}) |
| 873 | |
| 874 | if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil { |
| 875 | t.Fatal(err) |
| 876 | } |
| 877 | if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 { |
| 878 | t.Fatalf("route_paths = %v, want no row — the target belongs to another account", got) |
| 879 | } |
| 880 | } |
| 881 | |
| 882 | // TestReconcileSkipsUnknownTarget: a target that is not a local route yields |
| 883 | // no row, so the prefix falls through to the host's own route. That is the |
| 884 | // correct failure direction for a migration — a missing Go route serves |
| 885 | // classic rather than 404ing. |
| 886 | func TestReconcileSkipsUnknownTarget(t *testing.T) { |
| 887 | ctx := context.Background() |
| 888 | s := newTestStore(t) |
| 889 | reg := newTestRegistry(t) |
| 890 | mustRoute(t, reg, "ti.to", "tito", "classic") |
| 891 | mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to", |
| 892 | map[string]string{"paulca": "not-deployed-here.example"}) |
| 893 | |
| 894 | if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil { |
| 895 | t.Fatal(err) |
| 896 | } |
| 897 | if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 { |
| 898 | t.Fatalf("route_paths = %v, want no row for a target that is not a local route", got) |
| 899 | } |
| 900 | } |
| 901 | |
| 902 | // TestReconcileSkipsWhenClaimHostIsNotALocalRoute: the prefix map belongs to |
| 903 | // the hostname's own route. If that route lives on another box, this box |
| 904 | // derives nothing — the same per-box rule the alias half already follows. |
| 905 | func TestReconcileSkipsWhenClaimHostIsNotALocalRoute(t *testing.T) { |
| 906 | ctx := context.Background() |
| 907 | s := newTestStore(t) |
| 908 | reg := newTestRegistry(t) |
| 909 | mustRoute(t, reg, "go.tito.io", "tito", "classic") |
| 910 | mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to", |
| 911 | map[string]string{"paulca": "go.tito.io"}) |
| 912 | |
| 913 | if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil { |
| 914 | t.Fatal(err) |
| 915 | } |
| 916 | if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 { |
| 917 | t.Fatalf("route_paths = %v, want nothing — ti.to is not a route on this box", got) |
| 918 | } |
| 919 | } |
| 920 | |
| 921 | // TestReconcileCapsPathRows: a hand-written claim above the cap is truncated |
| 922 | // deterministically (sorted key order), never silently and never at random. |
| 923 | func TestReconcileCapsPathRows(t *testing.T) { |
| 924 | ctx := context.Background() |
| 925 | s := newTestStore(t) |
| 926 | reg := newTestRegistry(t) |
| 927 | mustRoute(t, reg, "ti.to", "tito", "classic") |
| 928 | mustRoute(t, reg, "go.tito.io", "tito", "classic") |
| 929 | big := make(map[string]string, MaxPathsPerClaim+10) |
| 930 | for i := 0; i < MaxPathsPerClaim+10; i++ { |
| 931 | big["acct"+strconv.Itoa(i)] = "go.tito.io" |
| 932 | } |
| 933 | mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to", big) |
| 934 | |
| 935 | if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil { |
| 936 | t.Fatal(err) |
| 937 | } |
| 938 | if got := pathsByPrefix(t, reg, "ti.to"); len(got) != MaxPathsPerClaim { |
| 939 | t.Fatalf("derived %d rows, want the cap of %d", len(got), MaxPathsPerClaim) |
| 940 | } |
| 941 | } |
| 942 | ``` |
| 943 | |
| 944 | Add these helpers to the same file (adapt the route/claim shapes to whatever the existing alias tests use): |
| 945 | |
| 946 | ```go |
| 947 | func mustRoute(t *testing.T, reg *registry.Registry, host, account, app string) { |
| 948 | t.Helper() |
| 949 | if err := reg.Upsert(registry.Route{ |
| 950 | Host: host, Kind: "service", Addr: "127.0.0.1:1", Status: "ready", |
| 951 | Account: account, App: app, |
| 952 | }); err != nil { |
| 953 | t.Fatalf("upsert route %s: %v", host, err) |
| 954 | } |
| 955 | } |
| 956 | |
| 957 | func mustClaimWithPaths(t *testing.T, ctx context.Context, s release.Store, |
| 958 | host, account, app, route string, paths map[string]string) { |
| 959 | t.Helper() |
| 960 | if _, _, err := AttachClaim(ctx, s, Claim{ |
| 961 | Schema: 1, Host: host, Account: account, App: app, Route: route, |
| 962 | CreatedAt: "2026-09-03T00:00:00Z", |
| 963 | }); err != nil { |
| 964 | t.Fatalf("attach claim %s: %v", host, err) |
| 965 | } |
| 966 | // Written directly rather than through SetClaimPaths so the cap test can |
| 967 | // stage an oversize map the door would refuse. |
| 968 | cur, etag, err := getClaimETag(ctx, s, host) |
| 969 | if err != nil { |
| 970 | t.Fatal(err) |
| 971 | } |
| 972 | cur.Paths = paths |
| 973 | body, err := json.Marshal(cur) |
| 974 | if err != nil { |
| 975 | t.Fatal(err) |
| 976 | } |
| 977 | if _, err := s.Put(ctx, ClaimKey(host), body, etag); err != nil { |
| 978 | t.Fatal(err) |
| 979 | } |
| 980 | } |
| 981 | ``` |
| 982 | |
| 983 | Import `encoding/json` and `github.com/carlosframework/platform/internal/release` in the test file. |
| 984 | |
| 985 | - [ ] **Step 2: Run the tests to verify they fail** |
| 986 | |
| 987 | Run: `go test ./internal/domains/ -run TestReconcile -count=1` |
| 988 | Expected: FAIL — the new tests find zero derived rows. |
| 989 | |
| 990 | - [ ] **Step 3: Derive the desired path set** |
| 991 | |
| 992 | In `internal/domains/aliases.go`, add `"sort"` to the imports. Alongside `desired` and `desiredWild` near the top of `ReconcileAliases`, add: |
| 993 | |
| 994 | ```go |
| 995 | desiredPaths := map[string]registry.RoutePath{} // key: host + "\x00" + prefix |
| 996 | ``` |
| 997 | |
| 998 | Then inside the `for _, c := range claims` loop, after the wildcard derivation, add: |
| 999 | |
| 1000 | ```go |
| 1001 | // Path derivation (prefix routing). A claim's Paths map splits its own |
| 1002 | // hostname's traffic by leading URL segment; each entry names the |
| 1003 | // hostname of the route that serves that segment. |
| 1004 | // |
| 1005 | // Gated on the claim's host being a LOCAL route whose tenancy matches |
| 1006 | // the claim — the same per-box rule the alias half above follows, and |
| 1007 | // for the same reason: the prefix map belongs to that route, so a box |
| 1008 | // that does not serve the route derives nothing. |
| 1009 | if len(c.Paths) > 0 { |
| 1010 | hostRt, ok := byHost[c.Host] |
| 1011 | if !ok { |
| 1012 | // The hostname's own route is not on this box; another box |
| 1013 | // derives these rows. |
| 1014 | } else if hostRt.App != c.App || release.RouteAccount(hostRt) != c.Account { |
| 1015 | log.Printf("domains: paths on %s refused: route belongs to %s/%s, claim says %s/%s", |
| 1016 | c.Host, release.RouteAccount(hostRt), hostRt.App, c.Account, c.App) |
| 1017 | } else { |
| 1018 | // Sorted so the cap below truncates DETERMINISTICALLY: every |
| 1019 | // box must derive the identical subset, or the same URL would |
| 1020 | // resolve differently depending on which edge answered. |
| 1021 | prefixes := make([]string, 0, len(c.Paths)) |
| 1022 | for p := range c.Paths { |
| 1023 | prefixes = append(prefixes, p) |
| 1024 | } |
| 1025 | sort.Strings(prefixes) |
| 1026 | if len(prefixes) > MaxPathsPerClaim { |
| 1027 | log.Printf("domains: paths on %s: %d prefixes exceeds the cap of %d, dropping %d in sorted order", |
| 1028 | c.Host, len(prefixes), MaxPathsPerClaim, len(prefixes)-MaxPathsPerClaim) |
| 1029 | prefixes = prefixes[:MaxPathsPerClaim] |
| 1030 | } |
| 1031 | for _, prefix := range prefixes { |
| 1032 | target := c.Paths[prefix] |
| 1033 | if !validPathPrefix(prefix) { |
| 1034 | log.Printf("domains: path %s/%s refused: not a bare path segment", c.Host, prefix) |
| 1035 | continue |
| 1036 | } |
| 1037 | tgtRt, ok := byHost[target] |
| 1038 | if !ok { |
| 1039 | // Target is not a route on this box. No row, so the |
| 1040 | // prefix falls through to the host's own route — the |
| 1041 | // right failure direction for a migration: a missing |
| 1042 | // destination serves the ORIGIN, never a 404. |
| 1043 | continue |
| 1044 | } |
| 1045 | // The authorization rule this feature adds: a prefix must |
| 1046 | // never point another tenant's traffic at your backend. |
| 1047 | // Account only — an account routing between its own apps |
| 1048 | // (classic and Go ARE two apps) is the whole use case. |
| 1049 | if release.RouteAccount(tgtRt) != c.Account { |
| 1050 | log.Printf("domains: path %s/%s refused: target %s belongs to %s, claim says %s", |
| 1051 | c.Host, prefix, target, release.RouteAccount(tgtRt), c.Account) |
| 1052 | continue |
| 1053 | } |
| 1054 | desiredPaths[c.Host+"\x00"+prefix] = registry.RoutePath{ |
| 1055 | Host: c.Host, Prefix: prefix, Target: target, |
| 1056 | Account: c.Account, App: c.App, |
| 1057 | } |
| 1058 | } |
| 1059 | } |
| 1060 | } |
| 1061 | ``` |
| 1062 | |
| 1063 | - [ ] **Step 4: Write and prune the table** |
| 1064 | |
| 1065 | At the end of `ReconcileAliases`, after the `wildcard_domains` deletion loop and before `return nil`: |
| 1066 | |
| 1067 | ```go |
| 1068 | // route_paths is wholly pass-owned, like route_aliases and |
| 1069 | // wildcard_domains: every row traces to a claim's Paths entry, so a |
| 1070 | // prefix removed from a republished map disappears here with no delete |
| 1071 | // verb anywhere. That is exactly how a per-account rollback reaches the |
| 1072 | // edge. |
| 1073 | existingPaths, err := reg.ListPaths() |
| 1074 | if err != nil { |
| 1075 | return err |
| 1076 | } |
| 1077 | for _, p := range desiredPaths { |
| 1078 | if err := reg.UpsertPath(p); err != nil { |
| 1079 | log.Printf("domains: upsert path %s/%s: %v", p.Host, p.Prefix, err) |
| 1080 | } |
| 1081 | } |
| 1082 | for _, p := range existingPaths { |
| 1083 | if _, ok := desiredPaths[p.Host+"\x00"+p.Prefix]; !ok { |
| 1084 | if err := reg.DeletePath(p.Host, p.Prefix); err != nil { |
| 1085 | log.Printf("domains: remove path %s/%s: %v", p.Host, p.Prefix, err) |
| 1086 | } |
| 1087 | } |
| 1088 | } |
| 1089 | return nil |
| 1090 | ``` |
| 1091 | |
| 1092 | `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. |
| 1093 | |
| 1094 | - [ ] **Step 5: Run the tests to verify they pass** |
| 1095 | |
| 1096 | Run: `go test ./internal/domains/ -count=1` |
| 1097 | Expected: PASS, including every existing alias and wildcard test — the pass's existing behaviour must be untouched. |
| 1098 | |
| 1099 | - [ ] **Step 6: Commit** |
| 1100 | |
| 1101 | ```bash |
| 1102 | git add internal/domains/aliases.go internal/domains/aliases_paths_test.go |
| 1103 | git commit -m "domains: derive route_paths from claims, gated on same-account targets |
| 1104 | |
| 1105 | Extends the pass that already materializes route_aliases and |
| 1106 | wildcard_domains, keeping the same wholly-pass-owned property: a prefix |
| 1107 | dropped from a republished map disappears on the next tick, so rollback needs |
| 1108 | no delete verb. |
| 1109 | |
| 1110 | Adds one authorization rule — a prefix's target must belong to the claim's |
| 1111 | account — so a prefix can never point another tenant's traffic at your |
| 1112 | backend. A target that is not a local route yields no row, so the prefix falls |
| 1113 | through to the hostname's own route: a missing destination serves the origin, |
| 1114 | never a 404. |
| 1115 | |
| 1116 | Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| 1117 | ``` |
| 1118 | |
| 1119 | --- |
| 1120 | |
| 1121 | ### Task 4: The edge resolves prefixes |
| 1122 | |
| 1123 | **Files:** |
| 1124 | - Modify: `internal/edge/edge.go` (`upstream` struct line 244; `Reload`'s alias fold ~line 591-606; `lookupUpstream` line 866; `ServeHTTP` dispatch line 930) |
| 1125 | - Test: `internal/edge/path_test.go` (create) |
| 1126 | |
| 1127 | **Interfaces:** |
| 1128 | - Consumes: `registry.RoutePath`, `(*Registry).ListPaths` (Task 1); `route_paths` rows (Task 3). |
| 1129 | - 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. |
| 1130 | |
| 1131 | - [ ] **Step 1: Write the failing tests** |
| 1132 | |
| 1133 | Create `internal/edge/path_test.go`: |
| 1134 | |
| 1135 | ```go |
| 1136 | package edge |
| 1137 | |
| 1138 | import ( |
| 1139 | "net/http" |
| 1140 | "net/http/httptest" |
| 1141 | "testing" |
| 1142 | |
| 1143 | "github.com/carlosframework/platform/internal/registry" |
| 1144 | ) |
| 1145 | |
| 1146 | // upsertPath writes one route_paths row, the shape the domains reconcile pass |
| 1147 | // derives from a claim's Paths map. |
| 1148 | func upsertPath(t *testing.T, reg *registry.Registry, host, prefix, target string) { |
| 1149 | t.Helper() |
| 1150 | if err := reg.UpsertPath(registry.RoutePath{ |
| 1151 | Host: host, Prefix: prefix, Target: target, Account: "acct", App: "app", |
| 1152 | }); err != nil { |
| 1153 | t.Fatalf("upsert path %s/%s -> %s: %v", host, prefix, target, err) |
| 1154 | } |
| 1155 | } |
| 1156 | |
| 1157 | // splitHost stands up one hostname whose default route is "classic" and whose |
| 1158 | // "flipped" prefix reaches a second route, returning the live test server. |
| 1159 | func splitHost(t *testing.T) *httptest.Server { |
| 1160 | t.Helper() |
| 1161 | classic, _ := hostEchoBackend(t, "classic") |
| 1162 | golive, _ := hostEchoBackend(t, "go") |
| 1163 | reg := openReg(t) |
| 1164 | if err := reg.Upsert(registry.Route{ |
| 1165 | Host: "ti.to", Kind: "service", |
| 1166 | Addr: classic.Listener.Addr().String(), Status: "ready", |
| 1167 | }); err != nil { |
| 1168 | t.Fatal(err) |
| 1169 | } |
| 1170 | if err := reg.Upsert(registry.Route{ |
| 1171 | Host: "go.tito.io", Kind: "service", |
| 1172 | Addr: golive.Listener.Addr().String(), Status: "ready", |
| 1173 | }); err != nil { |
| 1174 | t.Fatal(err) |
| 1175 | } |
| 1176 | upsertPath(t, reg, "ti.to", "flipped", "go.tito.io") |
| 1177 | |
| 1178 | e, err := New(reg, nil) |
| 1179 | if err != nil { |
| 1180 | t.Fatalf("New: %v", err) |
| 1181 | } |
| 1182 | srv := httptest.NewServer(e) |
| 1183 | t.Cleanup(srv.Close) |
| 1184 | return srv |
| 1185 | } |
| 1186 | |
| 1187 | // TestPathRouteReachesTarget is the feature: a flipped prefix reaches the |
| 1188 | // other route's backend while the same hostname's other traffic does not. |
| 1189 | func TestPathRouteReachesTarget(t *testing.T) { |
| 1190 | srv := splitHost(t) |
| 1191 | for _, tc := range []struct{ path, want string }{ |
| 1192 | {"/flipped", "go"}, |
| 1193 | {"/flipped/awesomeconf", "go"}, |
| 1194 | {"/flipped/awesomeconf/admin", "go"}, |
| 1195 | {"/notflipped", "classic"}, |
| 1196 | {"/notflipped/event", "classic"}, |
| 1197 | {"/", "classic"}, |
| 1198 | {"", "classic"}, |
| 1199 | } { |
| 1200 | code, body := getHostPath(t, srv.URL, "ti.to", tc.path) |
| 1201 | if code != http.StatusOK { |
| 1202 | t.Fatalf("%s: status = %d, want 200", tc.path, code) |
| 1203 | } |
| 1204 | if body != tc.want { |
| 1205 | t.Fatalf("%s: body = %q, want %q", tc.path, body, tc.want) |
| 1206 | } |
| 1207 | } |
| 1208 | } |
| 1209 | |
| 1210 | // TestPathMatchIsSegmentBounded is the boundary pin. A prefix match must be |
| 1211 | // segment-wise equality, never a string prefix — "flippedxyz" is a different |
| 1212 | // account from "flipped" and must not be dragged across the migration with it. |
| 1213 | func TestPathMatchIsSegmentBounded(t *testing.T) { |
| 1214 | srv := splitHost(t) |
| 1215 | for _, p := range []string{"/flippedxyz", "/flipped-two", "/flippedxyz/event"} { |
| 1216 | code, body := getHostPath(t, srv.URL, "ti.to", p) |
| 1217 | if code != http.StatusOK { |
| 1218 | t.Fatalf("%s: status = %d, want 200", p, code) |
| 1219 | } |
| 1220 | if body != "classic" { |
| 1221 | t.Fatalf("%s: body = %q, want classic — a prefix matches whole segments only", p, body) |
| 1222 | } |
| 1223 | } |
| 1224 | } |
| 1225 | |
| 1226 | // TestPathRouteDoesNotStripThePrefix: the upstream sees the original URL. The |
| 1227 | // flipped and unflipped URL spaces must be identical, which is the whole |
| 1228 | // point of a seamless migration. |
| 1229 | func TestPathRouteDoesNotStripThePrefix(t *testing.T) { |
| 1230 | classic, _ := hostEchoBackend(t, "classic") |
| 1231 | golive := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 1232 | w.Write([]byte(r.URL.Path)) |
| 1233 | })) |
| 1234 | t.Cleanup(golive.Close) |
| 1235 | |
| 1236 | reg := openReg(t) |
| 1237 | if err := reg.Upsert(registry.Route{ |
| 1238 | Host: "ti.to", Kind: "service", |
| 1239 | Addr: classic.Listener.Addr().String(), Status: "ready", |
| 1240 | }); err != nil { |
| 1241 | t.Fatal(err) |
| 1242 | } |
| 1243 | if err := reg.Upsert(registry.Route{ |
| 1244 | Host: "go.tito.io", Kind: "service", |
| 1245 | Addr: golive.Listener.Addr().String(), Status: "ready", |
| 1246 | }); err != nil { |
| 1247 | t.Fatal(err) |
| 1248 | } |
| 1249 | upsertPath(t, reg, "ti.to", "paulca", "go.tito.io") |
| 1250 | |
| 1251 | e, err := New(reg, nil) |
| 1252 | if err != nil { |
| 1253 | t.Fatal(err) |
| 1254 | } |
| 1255 | srv := httptest.NewServer(e) |
| 1256 | t.Cleanup(srv.Close) |
| 1257 | |
| 1258 | _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf") |
| 1259 | if body != "/paulca/awesomeconf" { |
| 1260 | t.Fatalf("upstream saw %q, want /paulca/awesomeconf — the prefix must NOT be stripped", body) |
| 1261 | } |
| 1262 | } |
| 1263 | |
| 1264 | // TestPathPreservesInboundHost: the upstream sees the hostname the browser |
| 1265 | // asked for, matching the alias contract. Tito Go must be able to build |
| 1266 | // absolute URLs that keep the visitor on ti.to. |
| 1267 | func TestPathPreservesInboundHost(t *testing.T) { |
| 1268 | classic, _ := hostEchoBackend(t, "classic") |
| 1269 | golive, seenHost := hostEchoBackend(t, "go") |
| 1270 | reg := openReg(t) |
| 1271 | if err := reg.Upsert(registry.Route{ |
| 1272 | Host: "ti.to", Kind: "service", |
| 1273 | Addr: classic.Listener.Addr().String(), Status: "ready", |
| 1274 | }); err != nil { |
| 1275 | t.Fatal(err) |
| 1276 | } |
| 1277 | if err := reg.Upsert(registry.Route{ |
| 1278 | Host: "go.tito.io", Kind: "service", |
| 1279 | Addr: golive.Listener.Addr().String(), Status: "ready", |
| 1280 | }); err != nil { |
| 1281 | t.Fatal(err) |
| 1282 | } |
| 1283 | upsertPath(t, reg, "ti.to", "paulca", "go.tito.io") |
| 1284 | |
| 1285 | e, err := New(reg, nil) |
| 1286 | if err != nil { |
| 1287 | t.Fatal(err) |
| 1288 | } |
| 1289 | srv := httptest.NewServer(e) |
| 1290 | t.Cleanup(srv.Close) |
| 1291 | |
| 1292 | getHostPath(t, srv.URL, "ti.to", "/paulca") |
| 1293 | if *seenHost != "ti.to" { |
| 1294 | t.Fatalf("upstream saw Host %q, want ti.to", *seenHost) |
| 1295 | } |
| 1296 | } |
| 1297 | |
| 1298 | // TestPathRowForMissingTargetFallsThrough: a row whose target is not in the |
| 1299 | // live map must serve the hostname's own route, never 404. This is the |
| 1300 | // failure direction the whole design depends on. |
| 1301 | func TestPathRowForMissingTargetFallsThrough(t *testing.T) { |
| 1302 | classic, _ := hostEchoBackend(t, "classic") |
| 1303 | reg := openReg(t) |
| 1304 | if err := reg.Upsert(registry.Route{ |
| 1305 | Host: "ti.to", Kind: "service", |
| 1306 | Addr: classic.Listener.Addr().String(), Status: "ready", |
| 1307 | }); err != nil { |
| 1308 | t.Fatal(err) |
| 1309 | } |
| 1310 | upsertPath(t, reg, "ti.to", "paulca", "vanished.example") |
| 1311 | |
| 1312 | e, err := New(reg, nil) |
| 1313 | if err != nil { |
| 1314 | t.Fatal(err) |
| 1315 | } |
| 1316 | srv := httptest.NewServer(e) |
| 1317 | t.Cleanup(srv.Close) |
| 1318 | |
| 1319 | code, body := getHostPath(t, srv.URL, "ti.to", "/paulca") |
| 1320 | if code != http.StatusOK || body != "classic" { |
| 1321 | t.Fatalf("status=%d body=%q, want 200/classic — a dangling target must fall through", code, body) |
| 1322 | } |
| 1323 | } |
| 1324 | |
| 1325 | // TestHostsWithoutPathsAreUnaffected: the additive guarantee. Every host on |
| 1326 | // the platform today has no prefix map and must behave exactly as before. |
| 1327 | func TestHostsWithoutPathsAreUnaffected(t *testing.T) { |
| 1328 | backend, _ := hostEchoBackend(t, "plain") |
| 1329 | reg := openReg(t) |
| 1330 | if err := reg.Upsert(registry.Route{ |
| 1331 | Host: "app.acct.oncarlos.com", Kind: "service", |
| 1332 | Addr: backend.Listener.Addr().String(), Status: "ready", |
| 1333 | }); err != nil { |
| 1334 | t.Fatal(err) |
| 1335 | } |
| 1336 | e, err := New(reg, nil) |
| 1337 | if err != nil { |
| 1338 | t.Fatal(err) |
| 1339 | } |
| 1340 | srv := httptest.NewServer(e) |
| 1341 | t.Cleanup(srv.Close) |
| 1342 | |
| 1343 | for _, p := range []string{"/", "/anything", "/deep/path/here"} { |
| 1344 | code, body := getHostPath(t, srv.URL, "app.acct.oncarlos.com", p) |
| 1345 | if code != http.StatusOK || body != "plain" { |
| 1346 | t.Fatalf("%s: status=%d body=%q, want 200/plain", p, code, body) |
| 1347 | } |
| 1348 | } |
| 1349 | } |
| 1350 | |
| 1351 | // TestFirstSegment is the matcher itself, tested directly because every |
| 1352 | // routing decision in this feature rests on it. |
| 1353 | func TestFirstSegment(t *testing.T) { |
| 1354 | for _, tc := range []struct{ in, want string }{ |
| 1355 | {"/paulca", "paulca"}, |
| 1356 | {"/paulca/", "paulca"}, |
| 1357 | {"/paulca/awesomeconf", "paulca"}, |
| 1358 | {"/paulca/awesomeconf/admin", "paulca"}, |
| 1359 | {"/", ""}, |
| 1360 | {"", ""}, |
| 1361 | {"paulca", ""}, // not an absolute path; never matches |
| 1362 | {"//", ""}, |
| 1363 | } { |
| 1364 | if got := firstSegment(tc.in); got != tc.want { |
| 1365 | t.Fatalf("firstSegment(%q) = %q, want %q", tc.in, got, tc.want) |
| 1366 | } |
| 1367 | } |
| 1368 | } |
| 1369 | ``` |
| 1370 | |
| 1371 | 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: |
| 1372 | |
| 1373 | ```go |
| 1374 | // getHostPath issues GET path against srv with an explicit Host header. |
| 1375 | func getHostPath(t *testing.T, srvURL, host, path string) (int, string) { |
| 1376 | t.Helper() |
| 1377 | req, err := http.NewRequest("GET", srvURL+path, nil) |
| 1378 | if err != nil { |
| 1379 | t.Fatal(err) |
| 1380 | } |
| 1381 | req.Host = host |
| 1382 | resp, err := http.DefaultClient.Do(req) |
| 1383 | if err != nil { |
| 1384 | t.Fatal(err) |
| 1385 | } |
| 1386 | defer resp.Body.Close() |
| 1387 | b, err := io.ReadAll(resp.Body) |
| 1388 | if err != nil { |
| 1389 | t.Fatal(err) |
| 1390 | } |
| 1391 | return resp.StatusCode, string(b) |
| 1392 | } |
| 1393 | ``` |
| 1394 | |
| 1395 | (Import `io`.) |
| 1396 | |
| 1397 | - [ ] **Step 2: Run the tests to verify they fail** |
| 1398 | |
| 1399 | Run: `go test ./internal/edge/ -run 'TestPath|TestFirstSegment|TestHostsWithoutPaths' -count=1` |
| 1400 | Expected: FAIL — `undefined: firstSegment`, and the routing tests serve "classic" for `/flipped`. |
| 1401 | |
| 1402 | - [ ] **Step 3: Add the `paths` field to `upstream`** |
| 1403 | |
| 1404 | In `internal/edge/edge.go`, inside the `upstream` struct after `alias`: |
| 1405 | |
| 1406 | ```go |
| 1407 | // paths, when non-nil, splits this hostname's traffic by leading URL |
| 1408 | // segment: a bare first segment ("paulca") mapped to the HOSTNAME of the |
| 1409 | // route that serves it, resolved against the same live map at request |
| 1410 | // time. Nil for every host that does not use prefix routing, which is |
| 1411 | // every host on the platform until a claim publishes a map — the nil |
| 1412 | // check in lookupUpstreamPath is the entire cost this feature imposes on |
| 1413 | // everyone else. |
| 1414 | // |
| 1415 | // Hostnames rather than resolved upstreams, deliberately: an upstream |
| 1416 | // captured here would be a stale copy of the target's entry the moment |
| 1417 | // the target's route changed within the same map build. |
| 1418 | paths map[string]string |
| 1419 | ``` |
| 1420 | |
| 1421 | - [ ] **Step 4: Fold `route_paths` into `Reload`** |
| 1422 | |
| 1423 | In `Reload`, immediately BEFORE the `aliases, err := e.reg.ListAliases()` block (line 591): |
| 1424 | |
| 1425 | ```go |
| 1426 | // Path routes, folded BEFORE aliases so an alias of a prefix-routed |
| 1427 | // hostname inherits its map along with everything else it copies — an |
| 1428 | // alias is meant to be indistinguishable from its primary, and a |
| 1429 | // hostname that routed by prefix under one name but not the other would |
| 1430 | // be a trap. |
| 1431 | // |
| 1432 | // Targets are recorded as hostnames, not resolved here: every route is |
| 1433 | // already in m by this point, but resolving now would freeze a copy of |
| 1434 | // the target's entry, and the target's own row may still be rewritten by |
| 1435 | // the alias fold below. |
| 1436 | paths, err := e.reg.ListPaths() |
| 1437 | if err != nil { |
| 1438 | return err |
| 1439 | } |
| 1440 | for _, p := range paths { |
| 1441 | up, ok := m[p.Host] |
| 1442 | if !ok { |
| 1443 | // The hostname has no route on this box. The reconcile pass |
| 1444 | // should not have produced this row; nothing to attach it to. |
| 1445 | continue |
| 1446 | } |
| 1447 | if up.paths == nil { |
| 1448 | up.paths = make(map[string]string) |
| 1449 | } |
| 1450 | up.paths[p.Prefix] = p.Target |
| 1451 | m[p.Host] = up |
| 1452 | } |
| 1453 | ``` |
| 1454 | |
| 1455 | - [ ] **Step 5: Split the lookup and add the path-aware form** |
| 1456 | |
| 1457 | 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`. |
| 1458 | |
| 1459 | ```go |
| 1460 | func lookupIn(m map[string]upstream, host string) (upstream, bool) { |
| 1461 | if up, ok := m[host]; ok { |
| 1462 | return up, true |
| 1463 | } |
| 1464 | if i := strings.IndexByte(host, '.'); i > 0 { |
| 1465 | if up, ok := m["*"+host[i:]]; ok { |
| 1466 | return up, true |
| 1467 | } |
| 1468 | } |
| 1469 | return upstream{}, false |
| 1470 | } |
| 1471 | |
| 1472 | // lookupUpstream resolves a hostname alone. Kept as the primitive because not |
| 1473 | // every caller is answering "where does this REQUEST go" — the routability |
| 1474 | // check in the ACME/HostPolicy path asks only whether this box serves the |
| 1475 | // hostname at all, and giving that question a path would make a hostname's |
| 1476 | // routability depend on which URL happened to ask. |
| 1477 | func (e *Edge) lookupUpstream(host string) (upstream, bool) { |
| 1478 | return lookupIn(e.current(), host) |
| 1479 | } |
| 1480 | |
| 1481 | // lookupUpstreamPath resolves a request: the hostname exactly as |
| 1482 | // lookupUpstream does, then — only for a hostname carrying a prefix map — the |
| 1483 | // first path segment. |
| 1484 | // |
| 1485 | // Both lookups read ONE snapshot of the map. Calling lookupUpstream and then |
| 1486 | // current() again would let a reload land between them and resolve a prefix |
| 1487 | // against a different table than the host came from. |
| 1488 | // |
| 1489 | // Every miss falls through to the hostname's own route rather than failing: |
| 1490 | // an unlisted prefix, a dangling target, a request for "/". A prefix map is a |
| 1491 | // migration in progress, and the un-migrated answer is always the safe one. |
| 1492 | func (e *Edge) lookupUpstreamPath(host, path string) (upstream, bool) { |
| 1493 | m := e.current() |
| 1494 | up, ok := lookupIn(m, host) |
| 1495 | if !ok || up.paths == nil { |
| 1496 | return up, ok |
| 1497 | } |
| 1498 | seg := firstSegment(path) |
| 1499 | if seg == "" { |
| 1500 | return up, true |
| 1501 | } |
| 1502 | target, ok := up.paths[seg] |
| 1503 | if !ok { |
| 1504 | return up, true |
| 1505 | } |
| 1506 | tu, ok := m[target] |
| 1507 | if !ok { |
| 1508 | return up, true |
| 1509 | } |
| 1510 | return tu, true |
| 1511 | } |
| 1512 | |
| 1513 | // firstSegment returns the first path segment of an absolute URL path, |
| 1514 | // without slashes: "/paulca/awesomeconf" -> "paulca". "" for "/", for the |
| 1515 | // empty string, and for anything not starting with "/". |
| 1516 | // |
| 1517 | // Returning the segment rather than testing a string prefix is what makes |
| 1518 | // matching segment-bounded: "/paulcarson" yields "paulcarson", which is not |
| 1519 | // equal to "paulca", so a longer account name can never be dragged across a |
| 1520 | // migration by a shorter one. |
| 1521 | func firstSegment(p string) string { |
| 1522 | if p == "" || p[0] != '/' { |
| 1523 | return "" |
| 1524 | } |
| 1525 | p = p[1:] |
| 1526 | if i := strings.IndexByte(p, '/'); i >= 0 { |
| 1527 | p = p[:i] |
| 1528 | } |
| 1529 | return p |
| 1530 | } |
| 1531 | ``` |
| 1532 | |
| 1533 | - [ ] **Step 6: Use the path-aware form in `ServeHTTP`** |
| 1534 | |
| 1535 | At `internal/edge/edge.go:930`, change: |
| 1536 | |
| 1537 | ```go |
| 1538 | up, ok := e.lookupUpstream(host) |
| 1539 | ``` |
| 1540 | |
| 1541 | to: |
| 1542 | |
| 1543 | ```go |
| 1544 | up, ok := e.lookupUpstreamPath(host, r.URL.Path) |
| 1545 | ``` |
| 1546 | |
| 1547 | Leave the call at `edge.go:1455` alone — that is the routability check the doc comment above `lookupUpstream` describes. |
| 1548 | |
| 1549 | 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. |
| 1550 | |
| 1551 | - [ ] **Step 7: Run the tests to verify they pass** |
| 1552 | |
| 1553 | Run: `go test ./internal/edge/ -count=1` |
| 1554 | Expected: PASS, including every existing edge test — especially the alias tests, which prove the alias fold still behaves. |
| 1555 | |
| 1556 | - [ ] **Step 8: Verify the whole build is clean** |
| 1557 | |
| 1558 | Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1` |
| 1559 | Expected: all clean. |
| 1560 | |
| 1561 | - [ ] **Step 9: Commit** |
| 1562 | |
| 1563 | ```bash |
| 1564 | git add internal/edge/edge.go internal/edge/path_test.go |
| 1565 | git commit -m "edge: resolve a leading path segment for hosts that carry a prefix map |
| 1566 | |
| 1567 | Host resolution is unchanged and stays the primitive: the routability check |
| 1568 | in the cert path asks only whether this box serves a hostname, and must not |
| 1569 | gain a path. Request dispatch gets a path-aware wrapper that reads one |
| 1570 | snapshot of the map for both lookups. |
| 1571 | |
| 1572 | Matching is segment-wise equality, so /paulcarson is never dragged across a |
| 1573 | migration by /paulca, and the prefix is not stripped, so the flipped and |
| 1574 | unflipped URL spaces stay identical. Every miss — unlisted prefix, dangling |
| 1575 | target, bare / — falls through to the hostname's own route. |
| 1576 | |
| 1577 | Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| 1578 | ``` |
| 1579 | |
| 1580 | --- |
| 1581 | |
| 1582 | ### Task 5: The write door and the read surfaces |
| 1583 | |
| 1584 | **Files:** |
| 1585 | - Modify: `internal/console/apidomains.go` (new handler, alongside the capabilities handler ~line 482) |
| 1586 | - Modify: `internal/console/authcli.go` (route registration, ~line 624) |
| 1587 | - Modify: `routes_cli.go` (path rows in `carlos routes`) |
| 1588 | - Test: `internal/console/apidomainpaths_test.go` (create) |
| 1589 | |
| 1590 | **Interfaces:** |
| 1591 | - Consumes: `domains.SetClaimPaths`, `domains.ErrTooManyPaths`, `domains.ErrBadPathPrefix`, `domains.MaxPathsPerClaim` (Task 2). |
| 1592 | - Produces: `PUT /api/cli/apps/{account}/{app}/domains/{host}/paths`. |
| 1593 | |
| 1594 | - [ ] **Step 1: Write the failing tests** |
| 1595 | |
| 1596 | 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. |
| 1597 | |
| 1598 | Cover, using that harness: |
| 1599 | |
| 1600 | 1. **`TestAPIDomainPathsPublishes`** — `PUT` with body `{"paths":{"paulca":"go.tito.io"}}` returns 200, and `domains.GetClaim` shows the map. |
| 1601 | 2. **`TestAPIDomainPathsReplaces`** — a second `PUT` with `{"paths":{"bigcorp":"go.tito.io"}}` leaves `bigcorp` only; `paulca` is gone. |
| 1602 | 3. **`TestAPIDomainPathsEmptyBodyClears`** — `{"paths":{}}` clears the map and returns 200. |
| 1603 | 4. **`TestAPIDomainPathsRejectsOtherAccount`** — a principal scoped to another account gets the family's byte-identical 404, not a 403. |
| 1604 | 5. **`TestAPIDomainPathsRejectsOversize`** — a map above `domains.MaxPathsPerClaim` returns 422. |
| 1605 | 6. **`TestAPIDomainPathsRejectsBadPrefix`** — `{"paths":{"/paulca":"go.tito.io"}}` returns 422. |
| 1606 | |
| 1607 | Each asserts the status code and, where it applies, reads the claim back to prove the effect. |
| 1608 | |
| 1609 | - [ ] **Step 2: Run the tests to verify they fail** |
| 1610 | |
| 1611 | Run: `go test ./internal/console/ -run TestAPIDomainPaths -count=1` |
| 1612 | Expected: FAIL — 404 from an unregistered route. |
| 1613 | |
| 1614 | - [ ] **Step 3: Add the handler** |
| 1615 | |
| 1616 | In `internal/console/apidomains.go`, following the shape of the capabilities handler: |
| 1617 | |
| 1618 | ```go |
| 1619 | // handleAPIDomainPaths answers `PUT /api/cli/apps/{account}/{app}/domains/{host}/paths`: |
| 1620 | // publish the WHOLE prefix map for a hostname the app already claims. |
| 1621 | // |
| 1622 | // Wholesale by contract. The caller regenerates the map from its own database |
| 1623 | // and publishes the result, so this replaces rather than merges — that is what |
| 1624 | // makes a republish a full repair and lets a removal express a rollback. There |
| 1625 | // is deliberately no per-prefix add or delete verb: two ways to write the same |
| 1626 | // state is two ways for it to drift from the caller's own. |
| 1627 | func (s *Server) handleAPIDomainPaths(w http.ResponseWriter, r *http.Request) { |
| 1628 | ctx := r.Context() |
| 1629 | acct, app, ok := s.apiAppScope(w, r) // the family's existing scope helper |
| 1630 | if !ok { |
| 1631 | return |
| 1632 | } |
| 1633 | host := r.PathValue("host") |
| 1634 | |
| 1635 | var body struct { |
| 1636 | Paths map[string]string `json:"paths"` |
| 1637 | } |
| 1638 | if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<20)).Decode(&body); err != nil { |
| 1639 | jsonError(w, http.StatusBadRequest, "could not parse the request body as JSON") |
| 1640 | return |
| 1641 | } |
| 1642 | |
| 1643 | changed, err := domains.SetClaimPaths(ctx, s.cfg.Releases, host, acct.Sqid, app, body.Paths) |
| 1644 | if errors.Is(err, domains.ErrTooManyPaths) { |
| 1645 | jsonError(w, http.StatusUnprocessableEntity, |
| 1646 | fmt.Sprintf("a hostname may carry at most %d path prefixes", domains.MaxPathsPerClaim)) |
| 1647 | return |
| 1648 | } else if errors.Is(err, domains.ErrBadPathPrefix) { |
| 1649 | jsonError(w, http.StatusUnprocessableEntity, |
| 1650 | "each path prefix must be a single bare segment: \"paulca\", not \"/paulca\"") |
| 1651 | return |
| 1652 | } else if errors.Is(err, domains.ErrHostUnavailable) { |
| 1653 | // The family's byte-identical 404 discipline: a caller who does not |
| 1654 | // hold this claim learns nothing about whether it exists. |
| 1655 | http.NotFound(w, r) |
| 1656 | return |
| 1657 | } else if err != nil { |
| 1658 | log.Printf("console: api domains paths %s/%s %s: %v", acct.Sqid, app, host, err) |
| 1659 | jsonError(w, http.StatusBadGateway, "could not update the hostname's path routes — try again") |
| 1660 | return |
| 1661 | } |
| 1662 | writeJSON(w, http.StatusOK, map[string]any{ |
| 1663 | "host": host, |
| 1664 | "paths": body.Paths, |
| 1665 | "changed": changed, |
| 1666 | }) |
| 1667 | } |
| 1668 | ``` |
| 1669 | |
| 1670 | 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. |
| 1671 | |
| 1672 | - [ ] **Step 4: Register the route** |
| 1673 | |
| 1674 | In `internal/console/authcli.go`, beside the other domain routes: |
| 1675 | |
| 1676 | ```go |
| 1677 | s.cliRoute("PUT /api/cli/apps/{account}/{app}/domains/{host}/paths", svcrole.Allow(svcrole.Publish), s.handleAPIDomainPaths) |
| 1678 | ``` |
| 1679 | |
| 1680 | `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. |
| 1681 | |
| 1682 | - [ ] **Step 5: Run the tests to verify they pass** |
| 1683 | |
| 1684 | Run: `go test ./internal/console/ -count=1` |
| 1685 | Expected: PASS. |
| 1686 | |
| 1687 | - [ ] **Step 6: Show path routes in `carlos routes`** |
| 1688 | |
| 1689 | 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: |
| 1690 | |
| 1691 | ```go |
| 1692 | // Path routes under their hostname: the flipped set has to be visible |
| 1693 | // from the command operators already use, or a migration is invisible |
| 1694 | // until something breaks. |
| 1695 | for _, p := range pathsFor(rt.Host) { |
| 1696 | fmt.Fprintf(tw, " /%s\t\t-> %s\t\t\t\t\n", p.Prefix, p.Target) |
| 1697 | } |
| 1698 | ``` |
| 1699 | |
| 1700 | 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). |
| 1701 | |
| 1702 | - [ ] **Step 7: Run the full suite** |
| 1703 | |
| 1704 | Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1` |
| 1705 | Expected: all clean. |
| 1706 | |
| 1707 | - [ ] **Step 8: Commit** |
| 1708 | |
| 1709 | ```bash |
| 1710 | git add internal/console/apidomains.go internal/console/authcli.go internal/console/apidomainpaths_test.go routes_cli.go |
| 1711 | git commit -m "console: publish a hostname's prefix map; show path routes in carlos routes |
| 1712 | |
| 1713 | PUT .../domains/{host}/paths replaces the whole map, reusing the domains |
| 1714 | family's auth, account scoping and byte-identical 404 discipline. Publish |
| 1715 | scope only — deciding where an account's traffic goes is not an |
| 1716 | instance-scoped decision. |
| 1717 | |
| 1718 | No write verb on the CLI: the tenant's platform is the source of truth, and a |
| 1719 | second writer would drift from it the first time anyone used it. |
| 1720 | |
| 1721 | Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| 1722 | ``` |
| 1723 | |
| 1724 | --- |
| 1725 | |
| 1726 | ### Task 6: End-to-end proof |
| 1727 | |
| 1728 | **Files:** |
| 1729 | - Test: `internal/edge/path_e2e_test.go` (create) |
| 1730 | |
| 1731 | **Interfaces:** |
| 1732 | - Consumes: everything from Tasks 1-4. |
| 1733 | - Produces: nothing. This is the deliverable that says the machinery works. |
| 1734 | |
| 1735 | 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. |
| 1736 | |
| 1737 | - [ ] **Step 1: Write the test** |
| 1738 | |
| 1739 | Create `internal/edge/path_e2e_test.go`: |
| 1740 | |
| 1741 | ```go |
| 1742 | package edge |
| 1743 | |
| 1744 | import ( |
| 1745 | "context" |
| 1746 | "net/http" |
| 1747 | "net/http/httptest" |
| 1748 | "testing" |
| 1749 | "time" |
| 1750 | |
| 1751 | "github.com/carlosframework/platform/internal/domains" |
| 1752 | "github.com/carlosframework/platform/internal/registry" |
| 1753 | ) |
| 1754 | |
| 1755 | // TestPathFlipEndToEnd walks the whole chain a real flip takes — claim |
| 1756 | // published, reconcile pass, fingerprint moves, edge reloads, traffic changes |
| 1757 | // — and then walks it backwards. Each earlier task proved one layer against a |
| 1758 | // hand-written input; this is the only test that proves they compose. |
| 1759 | // |
| 1760 | // The registry write and the edge reload are driven directly rather than |
| 1761 | // waited on: the 2s tick is a latency bound, not a correctness property, and |
| 1762 | // polling it would make this test slow and flaky. What must be true is that |
| 1763 | // the fingerprint MOVES, which is asserted rather than assumed. |
| 1764 | func TestPathFlipEndToEnd(t *testing.T) { |
| 1765 | ctx := context.Background() |
| 1766 | classic, _ := hostEchoBackend(t, "classic") |
| 1767 | golive, _ := hostEchoBackend(t, "go") |
| 1768 | |
| 1769 | reg := openReg(t) |
| 1770 | if err := reg.Upsert(registry.Route{ |
| 1771 | Host: "ti.to", Kind: "service", Account: "tito", App: "classic", |
| 1772 | Addr: classic.Listener.Addr().String(), Status: "ready", |
| 1773 | }); err != nil { |
| 1774 | t.Fatal(err) |
| 1775 | } |
| 1776 | if err := reg.Upsert(registry.Route{ |
| 1777 | Host: "go.tito.io", Kind: "service", Account: "tito", App: "go", |
| 1778 | Addr: golive.Listener.Addr().String(), Status: "ready", |
| 1779 | }); err != nil { |
| 1780 | t.Fatal(err) |
| 1781 | } |
| 1782 | |
| 1783 | store := newTestStore(t) // the in-memory release.Store the domains tests use |
| 1784 | if _, _, err := domains.AttachClaim(ctx, store, domains.Claim{ |
| 1785 | Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| 1786 | Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| 1787 | }); err != nil { |
| 1788 | t.Fatal(err) |
| 1789 | } |
| 1790 | |
| 1791 | e, err := New(reg, nil) |
| 1792 | if err != nil { |
| 1793 | t.Fatal(err) |
| 1794 | } |
| 1795 | srv := httptest.NewServer(e) |
| 1796 | defer srv.Close() |
| 1797 | |
| 1798 | // Before the flip: everything is classic. |
| 1799 | if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "classic" { |
| 1800 | t.Fatalf("before the flip: body = %q, want classic", body) |
| 1801 | } |
| 1802 | |
| 1803 | fpBefore, err := reg.Fingerprint() |
| 1804 | if err != nil { |
| 1805 | t.Fatal(err) |
| 1806 | } |
| 1807 | |
| 1808 | // The flip: Tito publishes its map, the pass folds it, the edge reloads. |
| 1809 | if _, err := domains.SetClaimPaths(ctx, store, "ti.to", "tito", "classic", |
| 1810 | map[string]string{"paulca": "go.tito.io"}); err != nil { |
| 1811 | t.Fatal(err) |
| 1812 | } |
| 1813 | if err := domains.ReconcileAliases(ctx, store, reg, time.Now, nil, nil); err != nil { |
| 1814 | t.Fatal(err) |
| 1815 | } |
| 1816 | fpAfter, err := reg.Fingerprint() |
| 1817 | if err != nil { |
| 1818 | t.Fatal(err) |
| 1819 | } |
| 1820 | if fpBefore == fpAfter { |
| 1821 | t.Fatal("fingerprint did not move across a flip — the edge's watcher would never reload") |
| 1822 | } |
| 1823 | if err := e.Reload(); err != nil { |
| 1824 | t.Fatal(err) |
| 1825 | } |
| 1826 | |
| 1827 | // After the flip: that account is on Go, its neighbours are not. |
| 1828 | if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "go" { |
| 1829 | t.Fatalf("after the flip: body = %q, want go", body) |
| 1830 | } |
| 1831 | if _, body := getHostPath(t, srv.URL, "ti.to", "/someoneelse/event"); body != "classic" { |
| 1832 | t.Fatalf("after the flip: a neighbouring account moved too, body = %q", body) |
| 1833 | } |
| 1834 | if _, body := getHostPath(t, srv.URL, "ti.to", "/paulcarson"); body != "classic" { |
| 1835 | t.Fatalf("after the flip: a longer account name was dragged along, body = %q", body) |
| 1836 | } |
| 1837 | |
| 1838 | // The rollback: republish without that account. |
| 1839 | if _, err := domains.SetClaimPaths(ctx, store, "ti.to", "tito", "classic", nil); err != nil { |
| 1840 | t.Fatal(err) |
| 1841 | } |
| 1842 | if err := domains.ReconcileAliases(ctx, store, reg, time.Now, nil, nil); err != nil { |
| 1843 | t.Fatal(err) |
| 1844 | } |
| 1845 | if err := e.Reload(); err != nil { |
| 1846 | t.Fatal(err) |
| 1847 | } |
| 1848 | if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "classic" { |
| 1849 | t.Fatalf("after the rollback: body = %q, want classic", body) |
| 1850 | } |
| 1851 | rows, err := reg.ListPaths() |
| 1852 | if err != nil { |
| 1853 | t.Fatal(err) |
| 1854 | } |
| 1855 | if len(rows) != 0 { |
| 1856 | t.Fatalf("after the rollback: %d path rows remain, want 0", len(rows)) |
| 1857 | } |
| 1858 | } |
| 1859 | ``` |
| 1860 | |
| 1861 | 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. |
| 1862 | |
| 1863 | - [ ] **Step 2: Run it** |
| 1864 | |
| 1865 | Run: `go test ./internal/edge/ -run TestPathFlipEndToEnd -count=1 -v` |
| 1866 | Expected: PASS. |
| 1867 | |
| 1868 | - [ ] **Step 3: Run the full suite** |
| 1869 | |
| 1870 | Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1` |
| 1871 | Expected: all clean. |
| 1872 | |
| 1873 | - [ ] **Step 4: Commit** |
| 1874 | |
| 1875 | ```bash |
| 1876 | git add internal/edge/path_e2e_test.go |
| 1877 | git commit -m "test: end-to-end path flip and rollback |
| 1878 | |
| 1879 | Walks claim -> reconcile -> fingerprint -> reload -> traffic, then walks it |
| 1880 | back. With ti.to not moving behind the edge, this is the only thing that |
| 1881 | proves the four layers compose rather than each working against a |
| 1882 | hand-written input. |
| 1883 | |
| 1884 | Asserts the fingerprint actually moves: a flip that reconciles correctly and |
| 1885 | never reaches the live map is the failure this feature is most exposed to. |
| 1886 | |
| 1887 | Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| 1888 | ``` |
| 1889 | |
| 1890 | - [ ] **Step 5: Open the PR** |
| 1891 | |
| 1892 | ```bash |
| 1893 | git push -u origin HEAD |
| 1894 | ``` |
| 1895 | |
| 1896 | 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. |
| 1897 | |
| 1898 | 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. |
| 1899 | |
| 1900 | --- |
| 1901 | |
| 1902 | ## Self-Review |
| 1903 | |
| 1904 | **Spec coverage** |
| 1905 | |
| 1906 | | Spec section | Task | |
| 1907 | |---|---| |
| 1908 | | §2 prefix selects a route; bare segments | 1 (table), 4 (lookup) | |
| 1909 | | §3 `Paths` claim field, wholesale publish, cap | 2 | |
| 1910 | | §4 `route_paths`, pass-owned, tenancy gates, `Slug` untouched | 1, 3 | |
| 1911 | | §5 `Fingerprint()` section + a test that the digest moves | 1 (unit), 6 (e2e) | |
| 1912 | | §6 `lookupUpstream(host, path)`, no stripping, segment bounds, three call sites | 4 | |
| 1913 | | §7 write door, `SetClaimPaths` single writer | 2, 5 | |
| 1914 | | §8 read-only surfaces | 5 | |
| 1915 | | §9 exclusions | Nothing implements them, by construction | |
| 1916 | | §10 rollout stages 1-5 | Tasks 1-6 | |
| 1917 | | §11 live checks | Carried into the PR body, not implemented | |
| 1918 | |
| 1919 | Spec §4's ruling that `Route.Slug` stays untouched is honoured by no task mentioning it. |
| 1920 | |
| 1921 | **Type consistency** |
| 1922 | |
| 1923 | `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. |
| 1924 | |
| 1925 | **Known soft spots, called out rather than papered over** |
| 1926 | |
| 1927 | - 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. |
| 1928 | - Task 6 may hit an import cycle; the fallback package is named in the step. |
| 1929 | - 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. |
| 1930 | |