Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,5 +88,5 @@ flagsmith flag list # list the flags in the current environment
## Conventions

- `--json` (or `FLAGSMITH_JSON_OUTPUT`) for machine-readable output; `--jq <expr>` to filter it.
- Static credentials: `FLAGSMITH_API_KEY` (Admin API), `FLAGSMITH_ENVIRONMENT_KEY` (SDK).
- Static credentials: `FLAGSMITH_API_KEY` (Admin API), `FLAGSMITH_ENVIRONMENT_KEY` (SDK). When self-hosting Flagsmith, append the host and port, doubling `-` and writing `.` and `:` as `_`: `https://flagsmith-staging.com:8000` reads `FLAGSMITH_API_KEY_flagsmith__staging_com_8000`.
- Self-hosted: `--api-url` or `FLAGSMITH_API_URL`.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.26

require (
github.com/Flagsmith/flagsmith-go-client/v5 v5.1.0
github.com/blang/semver/v4 v4.0.0
github.com/charmbracelet/huh v1.0.0
github.com/fatih/color v1.19.0
github.com/itchyny/gojq v0.12.19
Expand All @@ -18,7 +19,6 @@ require (
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/bubbletea v1.3.6 // indirect
Expand Down
42 changes: 42 additions & 0 deletions internal/api/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package api

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
)

// ServerVersion reports the version of the Flagsmith serving apiURL, from the
// unauthenticated /version endpoint — it answers what an instance supports
// before there is any credential to ask with. The value is the deployed image
// tag, which self-hosted instances often set to something that is not a version
// at all ("latest", a commit sha), so callers must read an unparseable result as
// unknown rather than old.
func ServerVersion(ctx context.Context, httpClient *http.Client, apiURL string) (string, error) {
u := strings.TrimRight(apiURL, "/") + "/version/"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", err
}
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("reaching %s: %w", u, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("%s returned %s", u, resp.Status)
}
var doc struct {
ImageTag string `json:"image_tag"`
}
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return "", fmt.Errorf("decoding %s: %w", u, err)
}
if doc.ImageTag == "" {
return "", errors.New(u + " carries no image tag")
}
return doc.ImageTag, nil
}
79 changes: 79 additions & 0 deletions internal/api/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package api

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)

func TestServerVersion(t *testing.T) {
t.Run("reports the image tag", func(t *testing.T) {
// Given
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/version/" {
t.Errorf("path = %q", r.URL.Path)
}
fmt.Fprint(w, `{"ci_commit_sha":"67853f3","image_tag":"2.262.0","is_saas":true,"package_versions":{".":"2.262.0"}}`)
}))
defer srv.Close()

// When
got, err := ServerVersion(context.Background(), srv.Client(), srv.URL+"/")

// Then
if err != nil {
t.Fatal(err)
}
if got != "2.262.0" {
t.Errorf("ServerVersion = %q, want 2.262.0", got)
}
})

t.Run("errors when the endpoint is absent", func(t *testing.T) {
// Given
srv := httptest.NewServer(http.NotFoundHandler())
defer srv.Close()

// When
_, err := ServerVersion(context.Background(), srv.Client(), srv.URL)

// Then
if err == nil {
t.Error("err = nil, want an error")
}
})

t.Run("errors when the body is not the version document", func(t *testing.T) {
// Given
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "<html>nope</html>")
}))
defer srv.Close()

// When
_, err := ServerVersion(context.Background(), srv.Client(), srv.URL)

// Then
if err == nil {
t.Error("err = nil, want an error")
}
})

t.Run("errors when the tag is missing", func(t *testing.T) {
// Given
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"ci_commit_sha":"67853f3"}`)
}))
defer srv.Close()

// When
_, err := ServerVersion(context.Background(), srv.Client(), srv.URL)

// Then
if err == nil {
t.Error("err = nil, want an error")
}
})
}
15 changes: 9 additions & 6 deletions internal/auth/kind.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,18 @@ const (

var legacyAuthtokenPattern = regexp.MustCompile(`^[0-9a-fA-F]{40}$`)

// ValidateMasterKey's rejections.
// ValidateMasterKey's rejections. Each reads as the predicate of a sentence
// whose subject is the variable the value came from, which only the caller
// knows: the Master API key variable is host-scoped, so naming it here would
// name the wrong one for every self-hosted instance.
var (
ErrServerSideKey = errors.New("FLAGSMITH_API_KEY contains a server-side environment key")
ErrLegacyAuthtoken = errors.New("FLAGSMITH_API_KEY contains a legacy user authtoken, which is not supported")
ErrNotMasterKey = errors.New("FLAGSMITH_API_KEY is not a Master API key (expected {prefix}.{secret})")
ErrServerSideKey = errors.New("holds a server-side environment key")
ErrLegacyAuthtoken = errors.New("holds a legacy user authtoken, which is not supported")
ErrNotMasterKey = errors.New("is not a Master API key (expected {prefix}.{secret})")
)

// ValidateMasterKey checks that a FLAGSMITH_API_KEY value is a Master API key.
// Each Admin API env var maps to exactly one credential kind, so the scheme is
// ValidateMasterKey checks that a credential value is a Master API key. Each
// Admin API env var maps to exactly one credential kind, so the scheme is
// never guessed from token shape; this only turns common paste-mistakes into
// actionable errors instead of a silently rejected request.
func ValidateMasterKey(value string) error {
Expand Down
12 changes: 11 additions & 1 deletion internal/auth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ func secureScheme(u *url.URL) bool {
return ip != nil && ip.IsLoopback()
}

// ErrNoDiscovery reports that an instance serves no authorization server
// metadata: a Flagsmith predating the OAuth login, or not a Flagsmith API.
var ErrNoDiscovery = errors.New("no authorization server metadata")

func Discover(ctx context.Context, httpClient *http.Client, apiURL string) (*Metadata, error) {
u := strings.TrimRight(apiURL, "/") + "/.well-known/oauth-authorization-server"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
Expand All @@ -138,8 +142,14 @@ func Discover(ctx context.Context, httpClient *http.Client, apiURL string) (*Met
return nil, bug.Mark(fmt.Errorf("reaching %s: %w", u, err))
}
defer resp.Body.Close()
// Only a 404 means the document is absent. Any other refusal comes from a
// server that has one, and saying otherwise would send a user with a
// rate-limited or briefly broken instance looking for a URL mistake.
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("%s returned %s: %w", u, resp.Status, ErrNoDiscovery)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s returned %s — is this a Flagsmith API URL?", u, resp.Status)
return nil, bug.Mark(fmt.Errorf("%s returned %s", u, resp.Status))
}
var md Metadata
if err := json.NewDecoder(resp.Body).Decode(&md); err != nil {
Expand Down
29 changes: 27 additions & 2 deletions internal/auth/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import (
"sync"
"testing"
"time"

"github.com/Flagsmith/flagsmith-cli/v2/internal/bug"
)

// fakeAuthServer implements just enough of the Flagsmith OAuth 2.1 surface
Expand Down Expand Up @@ -416,8 +418,31 @@ func TestDiscover(t *testing.T) {
_, err := Discover(context.Background(), http.DefaultClient, srv.URL)

// Then
if err == nil || !strings.Contains(err.Error(), "is this a Flagsmith API URL?") {
t.Errorf("err = %v, want a helpful not-Flagsmith hint", err)
if !errors.Is(err, ErrNoDiscovery) {
t.Errorf("err = %v, want ErrNoDiscovery", err)
}
})

// Only a 404 says the document is absent. A server that is failing or
// refusing has one, and callers act on the difference.
t.Run("a failing endpoint is not an absent one", func(t *testing.T) {
for _, status := range []int{http.StatusUnauthorized, http.StatusTooManyRequests, http.StatusInternalServerError} {
// Given
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
}))

// When
_, err := Discover(context.Background(), http.DefaultClient, srv.URL)
srv.Close()

// Then
if err == nil || errors.Is(err, ErrNoDiscovery) {
t.Errorf("%d: err = %v, want an error that is not ErrNoDiscovery", status, err)
}
if !errors.Is(err, bug.ErrUnexpected) {
t.Errorf("%d: err = %v, want it marked reportable", status, err)
}
}
})

Expand Down
12 changes: 11 additions & 1 deletion internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"context"
"errors"
"fmt"
"io"
"strconv"
Expand Down Expand Up @@ -86,7 +87,7 @@ func resolveCredential(ctx context.Context) (*activeCredential, error) {
func loadCredential(ctx context.Context) (*activeCredential, error) {
if name, v := envCredential(envAPIKey, apiURL, defaultAPIURL); v != "" {
if err := auth.ValidateMasterKey(v); err != nil {
return nil, err
return nil, fmt.Errorf("%s %w", name, err)
}
cred := &activeCredential{kind: auth.KindMaster, token: v, source: "$" + name, auth: api.APIKey(v)}
cred.apiClient = newAPIClient(cred.auth)
Expand All @@ -100,6 +101,15 @@ func loadCredential(ctx context.Context) (*activeCredential, error) {
}

creds, err := auth.Load(apiURL)
if errors.Is(err, auth.ErrNotLoggedIn) {
// Reporting "not logged in" to someone who did set a credential sends
// them to do what they just did. Their key is fine; its name isn't.
if set, use := ignoredUnscopedCredential(); set != "" {
return nil, hintf(fmt.Errorf("%s is set, but ignored for %s", set, apiURL),
"Credentials are host-scoped away from %s — set %s instead.",
urlHost(defaultAPIURL), use)
}
}
if err != nil {
return nil, err
}
Expand Down
Loading