From 177aeb2145c4ade0f7e28d232dda7c925b263d83 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Mon, 24 Aug 2026 06:43:14 -0700 Subject: [PATCH] docs: generate the route reference from every place routes are registered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route reference was built by scanning `internal/api` alone, so it omitted sixteen routes on a page whose own `read_when` invites an operator to audit what an instance exposes. Someone using it to enumerate exposure concluded `/debug/pprof/*` and `/-/metrics` were not served. Missing were the eleven operational, protocol and SPA routes wired up in `cmd/fanout` — `/-/metrics`, six `/debug/pprof/*`, `/mcp`, `/api/mcp`, and the SPA catch-all — and the five `/api/agent` routes in the agent runtime, which the caveat did not even mention because it named only the first group. `POST /api/agent` was missing for a second reason worth stating separately: a group's own root is registered as `group.POST("", ...)`, and the collector required a leading slash, so the one route that actually runs the investigator was dropped silently rather than refused. An empty relative path is now a real route when the receiver is a known group. `Any` registrations are collapsed to one row only when the middleware gives every method the same answer; where it would not, the build fails rather than publish a requirement that is wrong for some verb. Both current `Any` routes qualify. The scan list is a flag with each directory named. A directory in it that registers nothing is an error, so the list going stale is loud rather than a quietly shorter table — the same property the existing per-run check had, moved to per-directory. It counts what a directory registers rather than how much it adds to the total, because a route registered in two places would dedupe to nothing new and read as a directory registering none. The page now says what it covers instead of carrying a caveat, and states which groups are conditional: pprof off by default, MCP on by default, the agent only with a provider key. That distinction is what an exposure audit needs, and the previous text gave none of it. Each guard was verified by breaking it: an unregistered group prefix, a scan directory that registers nothing, and the per-directory count — the last fails the build over a duplicated directory if it counts newness instead. Routes went 45 to 61. Closes #188. --- cmd/fanout-docgen/groups.go | 3 +- cmd/fanout-docgen/main.go | 20 +- cmd/fanout-docgen/routes.go | 259 ++++++++++++------ cmd/fanout-docgen/routes_test.go | 117 +++++++- .../content/docs/reference/http-routes.mdx | 52 +++- 5 files changed, 350 insertions(+), 101 deletions(-) diff --git a/cmd/fanout-docgen/groups.go b/cmd/fanout-docgen/groups.go index 673b6f4f..2d98b5a8 100644 --- a/cmd/fanout-docgen/groups.go +++ b/cmd/fanout-docgen/groups.go @@ -21,6 +21,7 @@ import ( // registration is an error, never a guess. var groupPrefixes = map[string]string{ "ObservabilityHandler.Register": "/api/observability", + "Runtime.Register": "/api/agent", } // groupParams finds every `*echo.Group` parameter in a file and returns the @@ -51,7 +52,7 @@ func groupParams(file *ast.File, filename string) (map[string]string, error) { failure = fmt.Errorf( "%s: %s registers routes on an *echo.Group, whose paths are relative to a "+ "prefix declared at its call site in another package. Add %q to "+ - "groupPrefixes in cmd/fanout-docgen/routes.go with the prefix it is "+ + "groupPrefixes in cmd/fanout-docgen/groups.go with the prefix it is "+ "mounted at — without it those routes publish as requiring no credential", filename, name, name, ) diff --git a/cmd/fanout-docgen/main.go b/cmd/fanout-docgen/main.go index dd02c2e4..58370025 100644 --- a/cmd/fanout-docgen/main.go +++ b/cmd/fanout-docgen/main.go @@ -46,19 +46,24 @@ func main() { var ( source = flag.String("source", "internal/config/config.go", "path to the file declaring config.Config") alerts = flag.String("alert-source", "internal/alert/types.go", "path to the file declaring alert.AlertEnv") - apiDir = flag.String("api-dir", "internal/api", "directory whose files register HTTP routes") + // Every directory that registers routes. cmd/fanout carries the + // operational, protocol and SPA routes; internal/agent the investigator. + // A directory listed here that registers nothing is an error, so this + // list going stale is loud rather than a quietly shorter table. + routeDirs = flag.String("route-dirs", "internal/api,internal/agent,cmd/fanout", + "comma-separated directories whose files register HTTP routes") outDir = flag.String("out", "site/src/content/docs/reference", "reference root to write generated pages into") check = flag.Bool("check", false, "exit non-zero when a written page differs from the one on disk") ) flag.Parse() - if err := run(*source, *alerts, *apiDir, *outDir, *check); err != nil { + if err := run(*source, *alerts, *routeDirs, *outDir, *check); err != nil { fmt.Fprintf(os.Stderr, "fanout-docgen: %v\n", err) os.Exit(1) } } -func run(source, alertSource, apiDir, outDir string, check bool) error { +func run(source, alertSource, routeDirs, outDir string, check bool) error { fields, err := collect(source) if err != nil { return err @@ -82,7 +87,14 @@ func run(source, alertSource, apiDir, outDir string, check bool) error { // The HTTP surface. The paths come from the registrations; the authorization // requirement for each comes from the middleware's own decision function, // not from reading the switch that implements it. - routes, err := collectRoutes(apiDir) + dirs := strings.Split(routeDirs, ",") + for i, dir := range dirs { + dirs[i] = strings.TrimSpace(dir) + if dirs[i] == "" { + return fmt.Errorf("--route-dirs contains an empty entry: %q", routeDirs) + } + } + routes, err := collectRoutes(dirs) if err != nil { return err } diff --git a/cmd/fanout-docgen/routes.go b/cmd/fanout-docgen/routes.go index ec38c300..845f4432 100644 --- a/cmd/fanout-docgen/routes.go +++ b/cmd/fanout-docgen/routes.go @@ -17,8 +17,8 @@ import ( // routeCount is set by collectRoutes so the summary line can report it. var routeCount []api.RouteDoc -// collectRoutes finds every route registered under apiDir and asks the -// middleware how it classifies each one. +// collectRoutes finds every route the server registers and asks the middleware +// how it classifies each one. // // The two halves use deliberately different techniques, and the reason matters. // The paths can only come from the source, because a route is an `e.GET("...")` @@ -27,93 +27,114 @@ var routeCount []api.RouteDoc // method conditions, and a generator that re-implemented that reading would be a // second authorization model, free to drift from the one that runs. So the path // is parsed and the policy is asked. -func collectRoutes(apiDir string) ([]api.RouteDoc, error) { - entries, err := os.ReadDir(apiDir) - if err != nil { - return nil, err - } - +// +// It scans several directories because the surface is registered in several +// places: the application handlers in internal/api, the agent runtime, and the +// operational, protocol and SPA routes wired up in cmd/fanout. Scanning only +// internal/api published a table that omitted /-/metrics, /debug/pprof/*, /mcp +// and every /api/agent route, while the page invited an operator to audit what +// an instance exposes (#188). +func collectRoutes(dirs []string) ([]api.RouteDoc, error) { type reg struct{ method, path string } var found []reg seen := map[reg]bool{} - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || - strings.HasSuffix(entry.Name(), "_test.go") { - continue - } - - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, filepath.Join(apiDir, entry.Name()), nil, 0) - if err != nil { - return nil, fmt.Errorf("parsing %s: %w", entry.Name(), err) - } - - // Identifiers that are an *echo.Group parameter, mapped to the prefix - // that group is mounted at, so a relative path can be completed. - groupReceivers, err := groupParams(file, entry.Name()) + for _, dir := range dirs { + entries, err := os.ReadDir(dir) if err != nil { return nil, err } - ast.Inspect(file, func(n ast.Node) bool { - call, ok := n.(*ast.CallExpr) - if !ok || len(call.Args) == 0 { - return true - } - sel, ok := call.Fun.(*ast.SelectorExpr) - if !ok { - return true - } - switch sel.Sel.Name { - case "GET", "POST", "PUT", "PATCH", "DELETE": - default: - return true + // Counted per directory rather than as growth of `found`, because a + // route registered in two places would dedupe to nothing new and read + // as a directory that registers none. + registrations := 0 + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || + strings.HasSuffix(entry.Name(), "_test.go") { + continue } - lit, ok := call.Args[0].(*ast.BasicLit) - if !ok || lit.Kind != token.STRING { - return true + + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, filepath.Join(dir, entry.Name()), nil, 0) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", entry.Name(), err) } - path, err := strconv.Unquote(lit.Value) - if err != nil || !strings.HasPrefix(path, "/") { - return true + + // Identifiers that are an *echo.Group parameter, mapped to the prefix + // that group is mounted at, so a relative path can be completed. + groupReceivers, err := groupParams(file, entry.Name()) + if err != nil { + return nil, err } - // Complete a group-relative path. groupParams has already refused - // any group this generator cannot name, so an unresolved receiver - // here is the root Echo and the path is already absolute. - if recv, ok := sel.X.(*ast.Ident); ok { - if prefix, isGroup := groupReceivers[recv.Name]; isGroup { + + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok || len(call.Args) == 0 { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + switch sel.Sel.Name { + case "GET", "POST", "PUT", "PATCH", "DELETE", "Any": + default: + return true + } + lit, ok := call.Args[0].(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + path, err := strconv.Unquote(lit.Value) + if err != nil { + return true + } + + // Complete a group-relative path. groupParams has already refused + // any group this generator cannot name, so an unresolved receiver + // here is the root Echo and the path is already absolute. + prefix, onGroup := "", false + if recv, ok := sel.X.(*ast.Ident); ok { + prefix, onGroup = groupReceivers[recv.Name] + } + switch { + case onGroup: + // `group.POST("", ...)` registers the group's own root, which + // is a real route — POST /api/agent, the one that runs the + // investigator, is registered exactly that way. Requiring a + // leading slash here dropped it silently. path = prefix + path + case !strings.HasPrefix(path, "/"): + return true } - } - r := reg{method: sel.Sel.Name, path: path} - if !seen[r] { - seen[r] = true - found = append(found, r) - } - return true - }) - } - if len(found) == 0 { - return nil, fmt.Errorf( - "found no route registrations under %s; has the registration style changed?", - apiDir, - ) + registrations++ + r := reg{method: sel.Sel.Name, path: path} + if !seen[r] { + seen[r] = true + found = append(found, r) + } + return true + }) + } + + if registrations == 0 { + return nil, fmt.Errorf( + "found no route registrations under %s; has the registration style "+ + "changed, or did those routes move? A directory in the scan list "+ + "that registers nothing silently shrinks the published surface", + dir, + ) + } } docs := make([]api.RouteDoc, 0, len(found)) for _, r := range found { - doc, ok := api.DescribeRoute(r.method, r.path) - if !ok { - // A registered route the middleware does not classify is either - // unreachable or unprotected by accident. Neither is something to - // document quietly. - return nil, fmt.Errorf( - "route %s %s is registered but the auth middleware does not classify it; "+ - "add a case to classifyRoute or remove the route", - r.method, r.path, - ) + doc, err := describe(r.method, r.path) + if err != nil { + return nil, err } docs = append(docs, doc) } @@ -128,6 +149,68 @@ func collectRoutes(apiDir string) ([]api.RouteDoc, error) { return docs, nil } +// anyMethods are the methods checked before an `e.Any(...)` route is published +// as a single row. +// +// Echo v5 registers Any as a RouteAny sentinel that matches any method at all, +// so this is deliberately a subset: the six classifyRoute actually +// distinguishes. Checking those is what decides whether one row can state the +// requirement honestly, because a method the middleware does not distinguish +// cannot disagree with its neighbours. +var anyMethods = []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"} + +// describe asks the middleware to classify one registration. +// +// A registered route the middleware does not classify is either unreachable or +// unprotected by accident. Neither is something to document quietly, so it is +// an error rather than an omitted row. +// +// `Any` needs every method to reach the same answer before the table can carry +// a single row for it. Both current Any routes, /mcp and /api/mcp, classify +// identically for every method by construction. If that stops being true, +// collapsing them into one row would state a requirement that is wrong for some +// verb, so this refuses rather than picking one. +func describe(method, path string) (api.RouteDoc, error) { + if method != "Any" { + doc, ok := api.DescribeRoute(method, path) + if !ok { + return api.RouteDoc{}, fmt.Errorf( + "route %s %s is registered but the auth middleware does not classify it; "+ + "add a case to classifyRoute or remove the route", + method, path, + ) + } + return doc, nil + } + + var first api.RouteDoc + for i, m := range anyMethods { + doc, ok := api.DescribeRoute(m, path) + if !ok { + return api.RouteDoc{}, fmt.Errorf( + "route Any %s is registered, so it answers %s as well, but the auth "+ + "middleware does not classify that pair; add a case to classifyRoute", + path, m, + ) + } + if i == 0 { + first = doc + continue + } + if doc.Policy != first.Policy || doc.Capability != first.Capability { + return api.RouteDoc{}, fmt.Errorf( + "route Any %s classifies as %q for %s but %q for %s; it cannot be "+ + "published as one row without stating a requirement that is wrong "+ + "for one of them", + path, first.Policy, anyMethods[0], doc.Policy, m, + ) + } + } + first.Method = "Any" + first.Path = path + return first, nil +} + // requirement renders what a caller has to present, in a reader's terms rather // than the middleware's. func requirement(doc api.RouteDoc) string { @@ -152,8 +235,8 @@ func renderRoutes(routes []api.RouteDoc) []byte { b.WriteString("---\n") b.WriteString("title: \"HTTP routes\"\n") - b.WriteString("description: \"The HTTP routes registered in internal/api and what guards each one. Mostly the browser client's backend rather than a public API.\"\n") - b.WriteString("summary: \"The application routes and their authorization requirements, taken from the middleware that enforces them. Does not yet cover the operational and protocol routes registered outside internal/api.\"\n") + b.WriteString("description: \"Every HTTP route the server registers and what guards each one. Mostly the browser client's backend rather than a public API.\"\n") + b.WriteString("summary: \"Every route the server registers — application, operational, protocol and the SPA catch-all — with the authorization requirement for each, taken from the middleware that enforces them.\"\n") b.WriteString("read_when:\n") b.WriteString(" - \"You are auditing what an instance exposes and what guards each route.\"\n") b.WriteString(" - \"A request came back 401 or 403 and you want to know which capability it wanted.\"\n") @@ -161,18 +244,28 @@ func renderRoutes(routes []api.RouteDoc) []byte { b.WriteString("generated: true\n") b.WriteString("---\n\n") - b.WriteString("{/* Generated by cmd/fanout-docgen from internal/api. Edit the generator, not this page. */}\n\n") + b.WriteString("{/* Generated by cmd/fanout-docgen from internal/api, internal/agent and cmd/fanout. Edit the generator, not this page. */}\n\n") b.WriteString("Every route below is served on `FANOUT_HTTP_ADDR` (`:7520` by default).\n") b.WriteString("Telemetry does not arrive here — OTLP has its own two listeners, described in\n") b.WriteString("[send your first telemetry](/start/send-telemetry).\n\n") - b.WriteString(":::note[Not the whole surface yet]\n") - b.WriteString("This covers the routes registered in `internal/api`. The operational and\n") - b.WriteString("protocol routes — `/-/metrics`, `/debug/pprof/*`, `/mcp` and `/api/mcp` — are\n") - b.WriteString("registered elsewhere and are not in this table yet; they are described in\n") - b.WriteString("[endpoints](/reference/endpoints). Tracked in\n") - b.WriteString("[#188](https://github.com/labstack/fanout/issues/188).\n") + b.WriteString(":::note[What this table is, and what it is not]\n") + b.WriteString("Every route the binary registers, wherever it is registered — the application\n") + b.WriteString("handlers, the agent runtime, the operational and protocol endpoints, and the\n") + b.WriteString("SPA catch-all. A route the generator cannot get a classification for fails\n") + b.WriteString("the build, so a new route cannot quietly go undocumented.\n\n") + b.WriteString("It is the surface the binary *can* register, not the surface any particular\n") + b.WriteString("instance exposes. Some of it is conditional, so check the instance before\n") + b.WriteString("concluding a route is reachable — or that it is not:\n\n") + b.WriteString("- `/debug/pprof/*` — only when `FANOUT_PPROF_ENABLED` is true, which is\n") + b.WriteString(" **not** the default. Enabling it also turns on mutex and block sampling.\n") + b.WriteString("- `/mcp`, `/api/mcp`, `/oauth/*` and `/.well-known/*` — only when\n") + b.WriteString(" `FANOUT_MCP_ENABLED` is true, which **is** the default.\n") + b.WriteString("- `/api/agent` and `/api/agent/*` — only when an AI provider key is\n") + b.WriteString(" configured. Without one the investigator is not registered at all.\n\n") + b.WriteString("Everything else is always registered. A path shown with `:name` or `*` is the\n") + b.WriteString("pattern Echo matches on, not a literal URL.\n") b.WriteString(":::\n\n") b.WriteString(":::caution[Most of this is not a public API]\n") @@ -208,7 +301,11 @@ func renderRoutes(routes []api.RouteDoc) []byte { b.WriteString(" as part of their own protocol rather than through the middleware.\n") b.WriteString("- **a named capability** — the caller's role must carry it.\n") b.WriteString("- **or a service credential** — additionally reachable without a browser\n") - b.WriteString(" session, which is how a scraper reaches the metrics endpoint.\n") + b.WriteString(" session, which is how a scraper reaches the metrics endpoint.\n\n") + b.WriteString("A method of `Any` means the route answers every verb, and the middleware\n") + b.WriteString("gives the same answer for all of them — where it would not, this page fails\n") + b.WriteString("to build rather than pick one. `GET /*` is the single-page application:\n") + b.WriteString("anything not matched above is served the client, which is why it is public.\n") return []byte(b.String()) } diff --git a/cmd/fanout-docgen/routes_test.go b/cmd/fanout-docgen/routes_test.go index e03bf613..fd92fbcf 100644 --- a/cmd/fanout-docgen/routes_test.go +++ b/cmd/fanout-docgen/routes_test.go @@ -7,14 +7,17 @@ import ( "github.com/labstack/fanout/internal/api" ) -const apiDir = "../../internal/api" +// The directories the generator scans by default, from the same list main.go +// ships. A test that scanned only internal/api would keep passing through +// exactly the regression #188 was about. +var routeDirs = []string{"../../internal/api", "../../internal/agent", "../../cmd/fanout"} // The bug this guards: routes registered on an *echo.Group carry relative // paths, and classifyRoute's SPA catch-all reports any non-/api/ path as // public — so five telemetry endpoints were published as requiring no // credential, on a page whose prose promises that cannot happen. func TestCollectRoutesResolvesGroupPrefixes(t *testing.T) { - routes, err := collectRoutes(apiDir) + routes, err := collectRoutes(routeDirs) if err != nil { t.Fatalf("collectRoutes: %v", err) } @@ -22,6 +25,7 @@ func TestCollectRoutesResolvesGroupPrefixes(t *testing.T) { relative := map[string]bool{ "/overview": true, "/topology": true, "/logs": true, "/trace": true, "/performance": true, + "/threads": true, "/threads/:threadID": true, } for _, r := range routes { if relative[r.Path] { @@ -43,6 +47,81 @@ func TestCollectRoutesResolvesGroupPrefixes(t *testing.T) { } } +// A group's own root is registered as `group.POST("", ...)`. Requiring a +// leading slash dropped it silently, so the one route that actually runs the +// investigator was absent from a page an operator reads to see what an +// instance exposes. +func TestCollectRoutesIncludesGroupRootRegistrations(t *testing.T) { + routes, err := collectRoutes(routeDirs) + if err != nil { + t.Fatalf("collectRoutes: %v", err) + } + + for _, r := range routes { + if r.Method == "POST" && r.Path == "/api/agent" { + if r.Capability != "agent:run" { + t.Errorf("POST /api/agent requires %q, want agent:run", r.Capability) + } + return + } + } + t.Error("POST /api/agent is registered as group.POST(\"\") but is absent from the reference") +} + +// #188: the table was built from internal/api alone, so an operator auditing +// exposure would conclude the profiling and metrics endpoints were not served. +func TestCollectRoutesCoversRoutesRegisteredOutsideInternalAPI(t *testing.T) { + routes, err := collectRoutes(routeDirs) + if err != nil { + t.Fatalf("collectRoutes: %v", err) + } + + seen := map[string]api.RouteDoc{} + for _, r := range routes { + seen[r.Method+" "+r.Path] = r + } + + for _, want := range []struct{ key, capability string }{ + {"GET /-/metrics", "operations:read"}, + {"GET /debug/pprof/", "operations:read"}, + {"GET /debug/pprof/profile", "operations:read"}, + {"Any /api/mcp", "telemetry:read"}, + {"Any /mcp", ""}, + {"GET /", ""}, + {"GET /*", ""}, + } { + got, ok := seen[want.key] + if !ok { + t.Errorf("%s is registered but absent from the reference", want.key) + continue + } + if got.Capability != want.capability { + t.Errorf("%s requires %q, want %q", want.key, got.Capability, want.capability) + } + } +} + +// An `Any` registration answers every verb. Publishing one row for it is only +// honest while the middleware gives every verb the same answer. +func TestDescribeAnyCollapsesOnlyWhenEveryMethodAgrees(t *testing.T) { + doc, err := describe("Any", "/mcp") + if err != nil { + t.Fatalf("describe Any /mcp: %v", err) + } + if doc.Method != "Any" { + t.Errorf("method rendered as %q, want Any", doc.Method) + } + if doc.Policy != api.RoutePolicyProtocol { + t.Errorf("/mcp classified as %q, want %q", doc.Policy, api.RoutePolicyProtocol) + } + + // POST-only in the middleware, so an Any registration on it would publish a + // requirement that is wrong for GET. + if _, err := describe("Any", "/api/auth/setup"); err == nil { + t.Error("describe accepted an Any route the middleware classifies for only some methods") + } +} + // The roles matrix must agree with the middleware, which is why it is generated // at all: the hand-written one claimed viewer could not run the agent. func TestRenderRolesMatchesTheMiddleware(t *testing.T) { @@ -68,7 +147,7 @@ func TestRenderRolesMatchesTheMiddleware(t *testing.T) { // middleware's own answer rather than a description of it. So the test that // matters is that every registered route gets one. func TestCollectRoutesClassifiesEveryRegisteredRoute(t *testing.T) { - routes, err := collectRoutes(apiDir) + routes, err := collectRoutes(routeDirs) if err != nil { t.Fatalf("collectRoutes: %v", err) } @@ -88,15 +167,41 @@ func TestCollectRoutesClassifiesEveryRegisteredRoute(t *testing.T) { // Pointed at the generator's own package: it parses fine and registers nothing. // Emitting an empty route table would be worse than failing, because an empty -// table reads as "this server has no API". +// table reads as "this server has no API". Now that several directories are +// scanned, the same has to hold per directory — one that stops registering +// routes would otherwise just shrink the table. func TestCollectRoutesRejectsADirectoryWithNoRoutes(t *testing.T) { - if _, err := collectRoutes("."); err == nil { + if _, err := collectRoutes([]string{"."}); err == nil { t.Fatal("collectRoutes accepted a directory that registers no routes") } + if _, err := collectRoutes(append(append([]string{}, routeDirs...), ".")); err == nil { + t.Fatal("collectRoutes accepted a scan list containing a directory that registers no routes") + } +} + +// The per-directory guard must count what a directory registers, not how much +// it adds to the total. Counting growth means a route registered in two places +// dedupes to nothing new and the second directory reads as registering none — +// failing the build over a duplicate rather than over a real problem. +func TestCollectRoutesGuardCountsRegistrationsNotNewness(t *testing.T) { + dir := routeDirs[0] + routes, err := collectRoutes([]string{dir, dir}) + if err != nil { + t.Fatalf("collectRoutes refused a directory scanned twice: %v", err) + } + + once, err := collectRoutes([]string{dir}) + if err != nil { + t.Fatalf("collectRoutes: %v", err) + } + if len(routes) != len(once) { + t.Errorf("scanning %s twice yielded %d routes, want %d — duplicates are not deduped", + dir, len(routes), len(once)) + } } func TestRenderRoutesListsEveryRoute(t *testing.T) { - routes, err := collectRoutes(apiDir) + routes, err := collectRoutes(routeDirs) if err != nil { t.Fatalf("collectRoutes: %v", err) } diff --git a/site/src/content/docs/reference/http-routes.mdx b/site/src/content/docs/reference/http-routes.mdx index 1ee55b6d..2155b25a 100644 --- a/site/src/content/docs/reference/http-routes.mdx +++ b/site/src/content/docs/reference/http-routes.mdx @@ -1,7 +1,7 @@ --- title: "HTTP routes" -description: "The HTTP routes registered in internal/api and what guards each one. Mostly the browser client's backend rather than a public API." -summary: "The application routes and their authorization requirements, taken from the middleware that enforces them. Does not yet cover the operational and protocol routes registered outside internal/api." +description: "Every HTTP route the server registers and what guards each one. Mostly the browser client's backend rather than a public API." +summary: "Every route the server registers — application, operational, protocol and the SPA catch-all — with the authorization requirement for each, taken from the middleware that enforces them." read_when: - "You are auditing what an instance exposes and what guards each route." - "A request came back 401 or 403 and you want to know which capability it wanted." @@ -9,18 +9,31 @@ status: preview generated: true --- -{/* Generated by cmd/fanout-docgen from internal/api. Edit the generator, not this page. */} +{/* Generated by cmd/fanout-docgen from internal/api, internal/agent and cmd/fanout. Edit the generator, not this page. */} Every route below is served on `FANOUT_HTTP_ADDR` (`:7520` by default). Telemetry does not arrive here — OTLP has its own two listeners, described in [send your first telemetry](/start/send-telemetry). -:::note[Not the whole surface yet] -This covers the routes registered in `internal/api`. The operational and -protocol routes — `/-/metrics`, `/debug/pprof/*`, `/mcp` and `/api/mcp` — are -registered elsewhere and are not in this table yet; they are described in -[endpoints](/reference/endpoints). Tracked in -[#188](https://github.com/labstack/fanout/issues/188). +:::note[What this table is, and what it is not] +Every route the binary registers, wherever it is registered — the application +handlers, the agent runtime, the operational and protocol endpoints, and the +SPA catch-all. A route the generator cannot get a classification for fails +the build, so a new route cannot quietly go undocumented. + +It is the surface the binary *can* register, not the surface any particular +instance exposes. Some of it is conditional, so check the instance before +concluding a route is reachable — or that it is not: + +- `/debug/pprof/*` — only when `FANOUT_PPROF_ENABLED` is true, which is + **not** the default. Enabling it also turns on mutex and block sampling. +- `/mcp`, `/api/mcp`, `/oauth/*` and `/.well-known/*` — only when + `FANOUT_MCP_ENABLED` is true, which **is** the default. +- `/api/agent` and `/api/agent/*` — only when an AI provider key is + configured. Without one the investigator is not registered at all. + +Everything else is always registered. A path shown with `:name` or `*` is the +pattern Echo matches on, not a literal URL. ::: :::caution[Most of this is not a public API] @@ -45,9 +58,17 @@ Capabilities map to roles in [roles](/reference/roles). | Method | Path | Requires | |---|---|---| +| `GET` | `/` | none | +| `GET` | `/*` | none | +| `GET` | `/-/metrics` | `operations:read`, or a service credential | | `GET` | `/.well-known/oauth-authorization-server` | protocol handshake | | `GET` | `/.well-known/oauth-protected-resource` | protocol handshake | | `GET` | `/.well-known/oauth-protected-resource/mcp` | protocol handshake | +| `POST` | `/api/agent` | `agent:run` | +| `GET` | `/api/agent/threads` | `agent:run` | +| `DELETE` | `/api/agent/threads/:threadID` | `agent:run` | +| `GET` | `/api/agent/threads/:threadID` | `agent:run` | +| `PATCH` | `/api/agent/threads/:threadID` | `agent:run` | | `GET` | `/api/alerts` | `telemetry:read` | | `GET` | `/api/alerts/summary` | `telemetry:read` | | `POST` | `/api/auth/login-link` | none | @@ -69,6 +90,7 @@ Capabilities map to roles in [roles](/reference/roles). | `GET` | `/api/dashboards/:id` | `dashboards:manage-own` | | `PUT` | `/api/dashboards/:id` | `dashboards:manage-own` | | `GET` | `/api/health` | none | +| `Any` | `/api/mcp` | `telemetry:read` | | `GET` | `/api/observability/logs` | `telemetry:read` | | `GET` | `/api/observability/overview` | `telemetry:read` | | `GET` | `/api/observability/performance` | `telemetry:read` | @@ -86,7 +108,14 @@ Capabilities map to roles in [roles](/reference/roles). | `DELETE` | `/api/users/:id` | `users:manage` | | `PUT` | `/api/users/:id` | `users:manage` | | `POST` | `/api/users/:id/logout-all` | `users:manage` | +| `GET` | `/debug/pprof/` | `operations:read` | +| `GET` | `/debug/pprof/:name` | `operations:read` | +| `GET` | `/debug/pprof/cmdline` | `operations:read` | +| `GET` | `/debug/pprof/profile` | `operations:read` | +| `GET` | `/debug/pprof/symbol` | `operations:read` | +| `GET` | `/debug/pprof/trace` | `operations:read` | | `GET` | `/healthz` | none | +| `Any` | `/mcp` | protocol handshake | | `POST` | `/oauth/register` | protocol handshake | | `POST` | `/oauth/token` | protocol handshake | | `GET` | `/readyz` | none | @@ -102,3 +131,8 @@ Capabilities map to roles in [roles](/reference/roles). - **a named capability** — the caller's role must carry it. - **or a service credential** — additionally reachable without a browser session, which is how a scraper reaches the metrics endpoint. + +A method of `Any` means the route answers every verb, and the middleware +gives the same answer for all of them — where it would not, this page fails +to build rather than pick one. `GET /*` is the single-page application: +anything not matched above is served the client, which is why it is public.