diff --git a/cmd/axme/mesh.go b/cmd/axme/mesh.go index d9a0977..ac34c7b 100644 --- a/cmd/axme/mesh.go +++ b/cmd/axme/mesh.go @@ -4,12 +4,41 @@ import ( "context" "fmt" "os" + "strings" "github.com/spf13/cobra" ) const defaultMeshDashboardURL = "https://mesh.axme.ai" +// resolveDashboardURL picks the dashboard URL using this precedence: +// 1. explicit --dashboard-url flag (if non-empty and not equal to default) +// 2. AXME_MESH_DASHBOARD_URL env var +// 3. context-aware default: if gateway base_url looks like staging, the +// hardcoded prod URL will produce token mismatches — return empty string +// so the caller fails fast with a clear message +// 4. defaultMeshDashboardURL (prod) +func resolveDashboardURL(flagValue, gatewayBaseURL string) (string, error) { + if flagValue != "" && flagValue != defaultMeshDashboardURL { + return flagValue, nil + } + if envURL := strings.TrimSpace(os.Getenv("AXME_MESH_DASHBOARD_URL")); envURL != "" { + return envURL, nil + } + // Detect non-prod gateway: if the gateway is not api.cloud.axme.ai, the + // hardcoded prod dashboard URL will fail token exchange (token lives in a + // different environment's database). Refuse to open the wrong dashboard. + if gatewayBaseURL != "" && !strings.Contains(gatewayBaseURL, "api.cloud.axme.ai") { + return "", fmt.Errorf( + "current gateway is %q (non-prod). The default mesh dashboard at %s "+ + "only works with the prod gateway. Set AXME_MESH_DASHBOARD_URL to a dashboard "+ + "deployment connected to your gateway, or pass --dashboard-url", + gatewayBaseURL, defaultMeshDashboardURL, + ) + } + return defaultMeshDashboardURL, nil +} + func newMeshCmd(rt *runtime) *cobra.Command { cmd := &cobra.Command{ Use: "mesh", @@ -30,13 +59,30 @@ func newMeshDashboardCmd(rt *runtime) *cobra.Command { The command creates a one-time exchange token using your API key, then opens the dashboard in your default browser. The token is -valid for 5 minutes and can only be used once.`, +valid for 5 minutes and can only be used once. + +Dashboard URL precedence: + 1. --dashboard-url flag + 2. AXME_MESH_DASHBOARD_URL environment variable + 3. https://mesh.axme.ai (default, prod-only) + +Non-prod gateways (e.g. staging) will refuse to open the default URL because +the token would be created on the staging backend but the prod dashboard +would try to exchange it against the prod backend, producing +"Invalid exchange token". Use --dashboard-url or AXME_MESH_DASHBOARD_URL.`, RunE: func(cmd *cobra.Command, args []string) error { c := rt.effectiveContext() if c.APIKey == "" { return &cliError{Code: 2, Msg: "no API key configured. Run 'axme login' first."} } + // Resolve dashboard URL BEFORE creating the token, so we fail fast + // when the gateway is non-prod and no override is provided. + resolvedDashboardURL, urlErr := resolveDashboardURL(dashboardURL, c.BaseURL) + if urlErr != nil { + return &cliError{Code: 2, Msg: urlErr.Error()} + } + // Create exchange token fmt.Fprintf(os.Stderr, "Creating dashboard token...\n") status, body, _, err := rt.doRequest( @@ -60,7 +106,7 @@ valid for 5 minutes and can only be used once.`, return &cliError{Code: 1, Msg: "server returned empty token"} } - exchangeURL := fmt.Sprintf("%s/auth/exchange?token=%s", dashboardURL, token) + exchangeURL := fmt.Sprintf("%s/auth/exchange?token=%s", resolvedDashboardURL, token) if rt.outputJSON { rt.printJSON(map[string]any{ diff --git a/cmd/axme/mesh_test.go b/cmd/axme/mesh_test.go new file mode 100644 index 0000000..6101a1c --- /dev/null +++ b/cmd/axme/mesh_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +// resolveDashboardURL precedence: +// 1. explicit --dashboard-url flag overrides everything +// 2. AXME_MESH_DASHBOARD_URL env var +// 3. context-aware default (refuse non-prod gateway) +// 4. defaultMeshDashboardURL (prod) + +func TestResolveDashboardURL_FlagOverride(t *testing.T) { + t.Setenv("AXME_MESH_DASHBOARD_URL", "http://env-url.test") + got, err := resolveDashboardURL("https://flag-override.test", "https://api.cloud.axme.ai") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != "https://flag-override.test" { + t.Errorf("expected flag override to win, got %s", got) + } +} + +func TestResolveDashboardURL_FlagDefaultIgnored(t *testing.T) { + // If the user passes the default value, it should be treated as "no override" + // so env var/context-aware logic kicks in. + t.Setenv("AXME_MESH_DASHBOARD_URL", "http://env-url.test") + got, err := resolveDashboardURL(defaultMeshDashboardURL, "https://api.cloud.axme.ai") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != "http://env-url.test" { + t.Errorf("expected env var to win when flag is default, got %s", got) + } +} + +func TestResolveDashboardURL_EnvVar(t *testing.T) { + t.Setenv("AXME_MESH_DASHBOARD_URL", "http://env-url.test") + got, err := resolveDashboardURL("", "https://api.cloud.axme.ai") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != "http://env-url.test" { + t.Errorf("expected env var, got %s", got) + } +} + +func TestResolveDashboardURL_ProdDefault(t *testing.T) { + os.Unsetenv("AXME_MESH_DASHBOARD_URL") + got, err := resolveDashboardURL("", "https://api.cloud.axme.ai") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != defaultMeshDashboardURL { + t.Errorf("expected %s, got %s", defaultMeshDashboardURL, got) + } +} + +func TestResolveDashboardURL_StagingFailsFast(t *testing.T) { + os.Unsetenv("AXME_MESH_DASHBOARD_URL") + _, err := resolveDashboardURL("", "https://axme-gateway-staging-1047255398033.us-central1.run.app") + if err == nil { + t.Fatal("expected error on non-prod gateway") + } + if !strings.Contains(err.Error(), "non-prod") { + t.Errorf("expected 'non-prod' in error, got: %v", err) + } + if !strings.Contains(err.Error(), "AXME_MESH_DASHBOARD_URL") { + t.Errorf("expected env var hint in error, got: %v", err) + } +} + +func TestResolveDashboardURL_EmptyGatewayBaseURL(t *testing.T) { + // Empty base URL (e.g. fresh config) should fall through to default + os.Unsetenv("AXME_MESH_DASHBOARD_URL") + got, err := resolveDashboardURL("", "") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != defaultMeshDashboardURL { + t.Errorf("expected %s, got %s", defaultMeshDashboardURL, got) + } +}