diff --git a/AGENTS.md b/AGENTS.md index 5597f33..0890691 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,16 @@ band auth profiles # list all stored profiles band auth use admin # switch the active profile ``` -If a credential's `acct_scope` is "All" (system-scope), it can access any account but the CLI will show guidance about passing `--account-id`. Always pass `--account-id` explicitly with system-scope credentials. +If your credentials are not bound to a specific account, the CLI will prompt you to pass `--account-id` explicitly. Always pass `--account-id` on every command in that case. + +### Account Type and Capabilities + +`band auth status --plain` returns structured JSON describing what the active account can do. The two fields agents care about most: + +- **`build: true`** — this is a Bandwidth Build account. Voice-only, credit-based. Messaging, number ordering, sub-accounts, VCPs, 10DLC, and toll-free verification are not available; commands targeting those exit with code 4 and a clear message pointing at the upgrade path. +- **`capabilities`** — a derived map (`voice`, `messaging`, `numbers`, `vcp`, `campaign_management`, `tfv`, `app_management`) flipping `true`/`false` based on the credential's roles. Use this to gate work locally rather than discovering limits via 4xx errors. + +Branch on these before attempting feature-gated work. The CLI also fails fast at the moment you try a restricted command, but checking capabilities up front avoids wasted setup. ### Account Hint @@ -236,7 +245,7 @@ band auth status # confirm After calling `band account register`, stop and tell the user they need to complete setup in their browser. Do not attempt to poll or wait — the next CLI step (`band auth login`) requires credentials that are only available after the human finishes the browser flow. -**After login, the account already has a voice app and a phone number.** Build accounts ship with both pre-provisioned. Run `band app list --plain` and `band number list --plain` to discover them — do **not** call `app create` or `number order` on a fresh Build account, you already have what you need to make a call. +**After login, the account already has a voice app and a phone number.** Build accounts ship with both pre-provisioned. Run `band app list --plain` to discover the voice app — do **not** call `app create` or `number order` on a fresh Build account, you already have what you need to make a call. (`band number list` doesn't work on Build yet; the pre-provisioned number is reachable via the account portal and already wired to the default voice app.) --- @@ -460,10 +469,11 @@ band number list --plain # → all numbers on account |------|---------|------| | 0 | Success | Command completed | | 1 | General error | Missing flags, invalid input, unexpected failures | -| 2 | Auth/permission error | 401/403 — bad credentials, token expired, or credential lacks a required role (e.g., VCP, Campaign Management, TFV). An agent's branching logic should treat exit code 2 as "try a different path or escalate" rather than only "re-authenticate" | +| 2 | Auth error | 401 — bad credentials or token expired. Re-authenticate. | | 3 | Not found | 404 — resource doesn't exist | -| 4 | Conflict | 409 — duplicate resource or feature not enabled | +| 4 | Conflict / feature limit / payment required | 402, 409, or 403 due to a plan/role gate (e.g., Build account trying to message, missing VCP/Campaign Management/TFV role, out of credits, declined card). Non-retryable — stop and escalate to the user. | | 5 | Timeout | `--wait` exceeded `--timeout` | +| 7 | Rate limited / quota exceeded | 429 or concurrent-resource ceiling. Back off and retry. | **Use exit codes for control flow, not string parsing.** @@ -475,9 +485,13 @@ band number list --plain # → all numbers on account | "account ID not set" | 1 | No active account | `band auth switch ` or pass `--account-id` | | "credential verification failed" | 2 | Bad client ID or secret | Check credentials | | "API error 401" | 2 | Token expired or invalid | Re-run `band auth login` | -| "API error 403" | 2 | Credential lacks permission | Check roles — VCP role for UP voice, Campaign Management role for `tendlc`, TFV role for `tfv`. Could also mean the account doesn't have the Registration Center feature enabled. Escalate to account manager if unclear | +| "...isn't available on Bandwidth Build accounts" | 4 | Build account hit a feature outside its plan (messaging, numbers, VCPs, 10DLC, TFV) | Stop and tell the user — non-retryable. Upgrade path: https://www.bandwidth.com/talk-to-an-expert/ | +| "credential lacks the X role" | 4 | Credential lacks a role on a non-Build account | Escalate to the user's Bandwidth account manager to assign the role | +| "API error 402" / "Insufficient credits" | 4 | Out of credits, declined card, or no payment method on file | Stop and tell the user — non-retryable; they need to top up or fix billing | +| "API error 403" | 2 | True auth failure (token expired or invalid). Feature/role 403s now surface as exit 4 with a tailored message — see the rows above. | Re-run `band auth login` | | "API error 404" | 3 | Resource doesn't exist | Verify the ID; check you're on the right account | | "API error 409" | 4 | Conflict / duplicate | Use `--if-not-exists`; or feature not enabled on account | +| "API error 429" | 7 | Rate limited or quota exceeded | Back off and retry — eventually retryable | | "HTTP voice feature is required" | 4 | Legacy voice not available | Try VCP path (UP account) or contact support | | "required flag not set" | 1 | Missing a required flag | Check `--help` for required flags | @@ -685,6 +699,7 @@ band message send --from +19195551234 --to +15559876543 --app-id abc-123 --text ## Limitations +- **Bandwidth Build accounts are voice-only.** Detect via `band auth status --plain` (`build: true`). On a Build account, only voice and app-management commands work — `message send`, `number search`/`order`, `vcp *`, `subaccount *`, `tendlc *`, `tfv *` all exit 4 with a Build-aware message and an upgrade link. Pre-provisioned voice app and number ship with the account; `band number list` doesn't work yet (the number is reachable via the account portal). Build also has runtime limits not surfaced in `auth status` — verified-number-only outbound on Free Trial, a 30-min cap per call, a 5-concurrent-call ceiling. See [dev.bandwidth.com](https://dev.bandwidth.com/docs/voice/programmable-voice/build-free-trial) for current pricing and limits; treat any 402 (exit 4) as "out of credits, escalate" and any 429 (exit 7) as "back off and retry." - **No real-time call control.** The CLI can initiate calls and query state, but cannot receive or respond to mid-call callbacks. Dynamic call control requires a separate callback-handling server. - **No message delivery confirmation.** The CLI verifies your setup is correct before sending (app-location link, callback URL, campaign), but it cannot confirm whether a message was actually delivered. Delivery status (`message-delivered`, `message-failed`) arrives via webhooks on your callback server. The CLI's `message get` and `message list` return metadata only — not delivery status. - **No message content retrieval.** Bandwidth does not store message bodies. After sending, the message text is gone forever. `message get` and `message list` return timestamps, direction, and segment counts only. diff --git a/README.md b/README.md index 64e6726..066f76a 100644 --- a/README.md +++ b/README.md @@ -97,11 +97,9 @@ Then complete setup in your browser: Once your credentials are ready, run `band auth login` and you're off. -**What you get:** Every Build account ships with a voice application and a phone number already provisioned — no need to create them yourself. After login, run `band app list` and `band number list` to see them, and skip straight to [make a call](#make-a-call). +**What you get:** Every Build account ships with a voice application and a phone number already provisioned. Run `band auth status` to confirm your account type and capabilities, then `band app list` to see your pre-provisioned voice app — then you're ready to [make a call](#make-a-call). (Your pre-provisioned number is also visible in the Bandwidth App.) -**Important note**: a Bandwidth Build account is for our Voice API **only**. Usage limits and terms and conditions apply. If you would like to send -messages, order numbers, and more, you will need a full Bandwidth Account. [Talk to an expert](https://www.bandwidth.com/talk-to-an-expert/) to start -your onboarding process today. +**Build is voice-only.** Messaging, number ordering, sub-accounts, VCPs, 10DLC, and toll-free verification all require a full Bandwidth account. If you try one of those commands on a Build account, the CLI fails fast (exit code 4) and points you at the upgrade path. For current Build pricing, credit costs, and trial limits, see [dev.bandwidth.com](https://dev.bandwidth.com/docs/voice/programmable-voice/build-free-trial). [Talk to an expert](https://www.bandwidth.com/talk-to-an-expert/) when you're ready to upgrade. --- @@ -528,8 +526,9 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f | 1 | Bad input or unexpected error | | 2 | Authentication or permission problem | | 3 | Resource not found | -| 4 | Conflict (duplicate resource or missing feature) | +| 4 | Conflict, feature limit, or payment required (duplicate resource, missing role, plan limit, out of credits) | | 5 | Timed out waiting | +| 7 | Rate limited or quota exceeded (back off and retry) | --- @@ -564,7 +563,7 @@ This CLI is agent-native — not just "agent-compatible." The design principles: - **`--plain` everywhere.** Flat, stable JSON output. Auto-enabled when stdout is piped, so agents in pipelines don't need the flag. - **`--if-not-exists` for idempotency.** Create commands can be retried safely without duplicating resources. - **`--wait` for async operations.** Agents can't poll. `--wait` blocks until the number is active, the call completes, or the transcription is ready. -- **Structured exit codes.** 0 success, 2 auth, 3 not found, 4 conflict, 5 timeout. Use exit codes for control flow, not string parsing. +- **Structured exit codes.** 0 success, 2 auth, 3 not found, 4 conflict/feature limit, 5 timeout, 7 rate limit. Use exit codes for control flow, not string parsing. - **Env-var-driven auth.** `BW_CLIENT_ID` + `BW_CLIENT_SECRET` — no interactive prompts required. For the full agent reference — dependency chains, provisioning workflows, error patterns, and copy-pasteable scripts — see [AGENTS.md](AGENTS.md). diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index 55a8c32..19a5a06 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -43,11 +43,83 @@ func TestTokenURLForEnvironment(t *testing.T) { } } +func TestCapabilities(t *testing.T) { + tests := []struct { + name string + roles []string + want map[string]bool + }{ + { + name: "build account roles", + roles: []string{"HTTP Application Management", "HttpVoice", "brtcAccessRole"}, + want: map[string]bool{ + "voice": true, + "app_management": true, + "messaging": false, + "numbers": false, + "vcp": false, + "campaign_management": false, + "tfv": false, + }, + }, + { + name: "no roles", + roles: nil, + want: map[string]bool{ + "voice": false, + "app_management": false, + "messaging": false, + "numbers": false, + "vcp": false, + "campaign_management": false, + "tfv": false, + }, + }, + { + name: "messaging and voice", + roles: []string{"Messaging", "HttpVoice"}, + want: map[string]bool{ + "voice": true, + "app_management": false, + "messaging": true, + "numbers": false, + "vcp": false, + "campaign_management": false, + "tfv": false, + }, + }, + { + name: "campaign and tfv", + roles: []string{"Campaign Management", "TFV"}, + want: map[string]bool{ + "voice": false, + "app_management": false, + "messaging": false, + "numbers": false, + "vcp": false, + "campaign_management": true, + "tfv": true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Capabilities(tt.roles) + for k, want := range tt.want { + if got[k] != want { + t.Errorf("Capabilities[%q] = %v, want %v (roles=%v)", k, got[k], want, tt.roles) + } + } + }) + } +} + func TestParseJWTClaims(t *testing.T) { claims := map[string]any{ - "accounts": []string{"9900001", "9900002"}, - "acct_scope": "9900001", - "roles": []string{"admin"}, + "accounts": []string{"9900001", "9900002"}, + "roles": []string{"admin"}, + "express": true, } payload, _ := json.Marshal(claims) encoded := base64.RawURLEncoding.EncodeToString(payload) @@ -57,12 +129,15 @@ func TestParseJWTClaims(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if parsed.AcctScope != "9900001" { - t.Errorf("AcctScope = %q, want %q", parsed.AcctScope, "9900001") - } if len(parsed.Accounts) != 2 || parsed.Accounts[0] != "9900001" { t.Errorf("Accounts = %v, want [9900001 9900002]", parsed.Accounts) } + if !parsed.Build { + t.Errorf("Build = false, want true") + } + if len(parsed.Roles) != 1 || parsed.Roles[0] != "admin" { + t.Errorf("Roles = %v, want [admin]", parsed.Roles) + } } func TestParseJWTClaimsInvalidFormat(t *testing.T) { diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 3355150..23f8545 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -128,15 +128,15 @@ func runLogin(cmd *cobra.Command, args []string) error { } ui.Successf("Credentials verified") - // Step 2: Extract accounts and scope from JWT + // Step 2: Extract accounts from JWT claims, err := parseJWTClaims(token) if err != nil { return fmt.Errorf("reading token claims: %w", err) } accounts := claims.Accounts - if len(accounts) == 0 && claims.AcctScope != "" { - ui.Infof("Credential scope: %s (access to all accounts)", claims.AcctScope) + if len(accounts) == 0 { + ui.Infof("Your credentials are not bound to a specific account.") ui.Infof("Use --account-id on commands to target a specific account.") } @@ -160,6 +160,8 @@ func runLogin(cmd *cobra.Command, args []string) error { ClientID: clientID, Accounts: accounts, Environment: environment, + Roles: claims.Roles, + Build: claims.Build, } // Step 5: Select active account @@ -231,9 +233,9 @@ func selectAccount(cmd *cobra.Command, accounts []string) string { } type jwtClaims struct { - Accounts []string `json:"accounts"` - AcctScope string `json:"acct_scope"` - Roles []string `json:"roles"` + Accounts []string `json:"accounts"` + Roles []string `json:"roles"` + Build bool `json:"express"` } func parseJWTClaims(token string) (*jwtClaims, error) { diff --git a/cmd/auth/status.go b/cmd/auth/status.go index fb18b5f..7198c80 100644 --- a/cmd/auth/status.go +++ b/cmd/auth/status.go @@ -1,6 +1,7 @@ package auth import ( + "encoding/json" "fmt" "os" "strings" @@ -8,6 +9,7 @@ import ( "github.com/spf13/cobra" intauth "github.com/Bandwidth/cli/internal/auth" + "github.com/Bandwidth/cli/internal/cmdutil" "github.com/Bandwidth/cli/internal/config" "github.com/Bandwidth/cli/internal/ui" ) @@ -22,7 +24,24 @@ var statusCmd = &cobra.Command{ RunE: runStatus, } +// statusJSON is the structured output shape returned when --plain is set. +// Stable contract for agents — additive changes only. +type statusJSON struct { + Authenticated bool `json:"authenticated"` + Profile string `json:"profile,omitempty"` + ClientID string `json:"client_id,omitempty"` + AccountID string `json:"account_id,omitempty"` + Accounts []string `json:"accounts,omitempty"` + Environment string `json:"environment,omitempty"` + Build bool `json:"build,omitempty"` + Roles []string `json:"roles,omitempty"` + Capabilities map[string]bool `json:"capabilities,omitempty"` + Error string `json:"error,omitempty"` +} + func runStatus(cmd *cobra.Command, args []string) error { + _, plain := cmdutil.OutputFlags(cmd) + configPath, err := config.DefaultPath() if err != nil { return fmt.Errorf("resolving config path: %w", err) @@ -36,6 +55,9 @@ func runStatus(cmd *cobra.Command, args []string) error { p := cfg.ActiveProfileConfig() if p.ClientID == "" { + if plain { + return emitJSON(statusJSON{Authenticated: false}) + } fmt.Fprintln(os.Stderr, ui.Warn("Not logged in.")) return nil } @@ -45,26 +67,42 @@ func runStatus(cmd *cobra.Command, args []string) error { env = "prod" } - // Show environment only when it's informative: either the user is on a - // non-default environment or they have profiles spanning multiple environments. - showEnv := env != "prod" || cfg.HasMultipleEnvironments() + profileName := cfg.ActiveProfile + if profileName == "" { + profileName = "default" + } + + _, keychainErr := intauth.GetPassword(p.ClientID) - _, err = intauth.GetPassword(p.ClientID) - if err != nil { + if plain { + out := statusJSON{ + Authenticated: keychainErr == nil, + Profile: profileName, + ClientID: p.ClientID, + AccountID: p.AccountID, + Accounts: p.Accounts, + Environment: env, + Build: p.Build, + Roles: p.Roles, + Capabilities: Capabilities(p.Roles), + } + if keychainErr != nil { + out.Error = "credentials not found in keychain" + } + return emitJSON(out) + } + + if keychainErr != nil { fmt.Printf("Client ID: %s\n", ui.ID(p.ClientID)) fmt.Printf("Account: %s\n", ui.ID(p.AccountID)) - if showEnv { + // Show environment only when it's informative. + if env != "prod" || cfg.HasMultipleEnvironments() { fmt.Printf("Environment: %s\n", env) } fmt.Println("Status: " + ui.Error("credentials not found in keychain")) return nil } - profileName := cfg.ActiveProfile - if profileName == "" { - profileName = "default" - } - fmt.Printf("Profile: %s\n", ui.Bold(profileName)) fmt.Printf("Client ID: %s\n", ui.ID(p.ClientID)) if p.AccountID != "" { @@ -77,7 +115,11 @@ func runStatus(cmd *cobra.Command, args []string) error { } else if len(p.Accounts) == 0 && p.AccountID == "" { fmt.Println("Scope: system-wide (use --account-id to target an account)") } - if showEnv { + if p.Build { + fmt.Printf("Type: %s (voice-only, credit-based)\n", ui.Bold("Bandwidth Build")) + fmt.Printf("Capable of: %s\n", capabilitySummary(Capabilities(p.Roles))) + } + if env != "prod" || cfg.HasMultipleEnvironments() { fmt.Printf("Environment: %s\n", env) } fmt.Println("Status: " + ui.Success("authenticated")) @@ -87,3 +129,77 @@ func runStatus(cmd *cobra.Command, args []string) error { } return nil } + +func emitJSON(v statusJSON) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +// Capabilities maps a set of JWT role strings to a stable feature map. +// Unknown roles are ignored; absence of a known role means the capability +// is false. Conservative by design — better to omit than to over-promise. +func Capabilities(roles []string) map[string]bool { + caps := map[string]bool{ + "voice": false, + "app_management": false, + "messaging": false, + "numbers": false, + "vcp": false, + "campaign_management": false, + "tfv": false, + } + for _, r := range roles { + rl := strings.ToLower(r) + if strings.Contains(rl, "httpvoice") || strings.Contains(rl, " voice") { + caps["voice"] = true + } + if strings.Contains(rl, "application management") || strings.Contains(rl, "app management") { + caps["app_management"] = true + } + if strings.Contains(rl, "messag") || strings.Contains(rl, "sms") { + caps["messaging"] = true + } + if strings.Contains(rl, "number") { + caps["numbers"] = true + } + if strings.Contains(rl, "vcp") || strings.Contains(rl, "voice configuration") { + caps["vcp"] = true + } + if strings.Contains(rl, "campaign") { + caps["campaign_management"] = true + } + if strings.Contains(rl, "tfv") || strings.Contains(rl, "toll-free") || strings.Contains(rl, "tollfree") { + caps["tfv"] = true + } + } + return caps +} + +// capabilitySummary renders a capability map as a "have / not" line +// for the human-readable auth status output on Build accounts. +func capabilitySummary(caps map[string]bool) string { + labels := map[string]string{ + "voice": "voice", + "app_management": "app management", + "messaging": "messaging", + "numbers": "number ordering", + "vcp": "VCP", + "campaign_management": "10DLC campaigns", + "tfv": "toll-free verification", + } + order := []string{"voice", "app_management", "messaging", "numbers", "vcp", "campaign_management", "tfv"} + var have, missing []string + for _, k := range order { + if caps[k] { + have = append(have, labels[k]) + } else { + missing = append(missing, labels[k]) + } + } + out := strings.Join(have, ", ") + if len(missing) > 0 { + out += " " + ui.Muted("(no "+strings.Join(missing, ", ")+")") + } + return out +} diff --git a/cmd/message/send.go b/cmd/message/send.go index de21afa..b16ab70 100644 --- a/cmd/message/send.go +++ b/cmd/message/send.go @@ -136,6 +136,15 @@ func runSend(cmd *cobra.Command, args []string) error { return err } + // Build accounts are voice-only — short-circuit before the dashboard + // preflight, which otherwise flags the pre-provisioned sample callback + // as a placeholder and points users at an irrelevant fix. + if cmdutil.ActiveBuild() { + return cmdutil.NewFeatureLimit( + "sending messages: Bandwidth Build accounts are voice-only — this requires a full Bandwidth account.\n"+ + "Talk to an expert: https://www.bandwidth.com/talk-to-an-expert/", nil) + } + // Preflight: verify the messaging app is linked to a location. dashClient, dashAcctID, dashErr := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) if dashErr == nil { diff --git a/cmd/number/list.go b/cmd/number/list.go index 20bf5b7..6ae6b82 100644 --- a/cmd/number/list.go +++ b/cmd/number/list.go @@ -78,7 +78,7 @@ func fetchAccountNumbers(client *api.Client, acctID, status string) ([]string, e var result interface{} if err := client.Get("/tns?"+q.Encode(), &result); err != nil { - return nil, wrapTNsError(err, acctID) + return nil, wrapTNsError(err, acctID, cmdutil.ActiveBuild()) } batch := extractFullNumbers(result) @@ -92,22 +92,27 @@ func fetchAccountNumbers(client *api.Client, acctID, status string) ([]string, e tnsMaxPages, tnsMaxPages*tnsMaxPageSize) } -// wrapTNsError annotates /tns errors with actionable context. The endpoint -// returns an empty body on 403, so the raw APIError message is just -// "API error 403:" — not useful to the user. The most common 403 cases are -// Build (express) credentials, which don't yet include the Numbers role -// (coming in a future Build update), and regular credentials missing the role. -func wrapTNsError(err error, acctID string) error { +// wrapTNsError annotates /tns errors with actionable context. /tns returns +// an empty body on 403, so the raw APIError message ("API error 403:") is +// not useful to the user. Build accounts get a tailored hint that their +// pre-provisioned number is reachable via the account portal; other +// accounts are pointed at the Numbers role. isBuild is parameterized so +// the function is testable without depending on a loaded config. +func wrapTNsError(err error, acctID string, isBuild bool) error { var apiErr *api.APIError - if errors.As(err, &apiErr) && apiErr.StatusCode == 403 { - return fmt.Errorf("listing phone numbers: credential lacks the Numbers role on account %s.\n"+ - "Build credentials don't include this role yet — it'll be added in an upcoming\n"+ - "Build update. In the meantime, your pre-provisioned number is visible in the\n"+ - "Bandwidth account portal and is already wired to the default voice application.\n"+ - "For non-Build accounts, contact your Bandwidth account manager to grant the\n"+ - "Numbers role: %w", acctID, err) + if !errors.As(err, &apiErr) || apiErr.StatusCode != 403 { + return fmt.Errorf("listing phone numbers: %w", err) } - return fmt.Errorf("listing phone numbers: %w", err) + if isBuild { + return cmdutil.NewFeatureLimit( + "phone number listing isn't available on Bandwidth Build accounts yet.\n"+ + "Your pre-provisioned number is visible in the Bandwidth account portal\n"+ + "and is already wired to the default voice application. Listing support\n"+ + "is planned for an upcoming Build update.", err) + } + return cmdutil.NewFeatureLimit(fmt.Sprintf( + "listing phone numbers: credential lacks the Numbers role on account %s.\n"+ + "Contact your Bandwidth account manager to assign this role.", acctID), err) } // extractFullNumbers walks a decoded /tns response and returns each diff --git a/cmd/number/number_test.go b/cmd/number/number_test.go index e8bda60..bce2e40 100644 --- a/cmd/number/number_test.go +++ b/cmd/number/number_test.go @@ -125,9 +125,9 @@ func TestExtractFullNumbers_Empty(t *testing.T) { } } -func TestWrapTNsError_403(t *testing.T) { +func TestWrapTNsError_403_NonBuild(t *testing.T) { apiErr := &api.APIError{StatusCode: 403, Body: ""} - err := wrapTNsError(apiErr, "9901409") + err := wrapTNsError(apiErr, "9901409", false) if err == nil { t.Fatal("expected non-nil error") } @@ -138,9 +138,6 @@ func TestWrapTNsError_403(t *testing.T) { if !strings.Contains(msg, "Numbers role") { t.Errorf("error should mention missing role, got %q", msg) } - if !strings.Contains(msg, "Build credentials") { - t.Errorf("error should mention Build credentials, got %q", msg) - } // Must preserve the underlying APIError for exit-code mapping. var unwrapped *api.APIError if !errors.As(err, &unwrapped) || unwrapped.StatusCode != 403 { @@ -148,8 +145,28 @@ func TestWrapTNsError_403(t *testing.T) { } } +func TestWrapTNsError_403_Build(t *testing.T) { + apiErr := &api.APIError{StatusCode: 403, Body: ""} + err := wrapTNsError(apiErr, "9901409", true) + if err == nil { + t.Fatal("expected non-nil error") + } + msg := err.Error() + if !strings.Contains(msg, "Bandwidth Build") { + t.Errorf("Build-account 403 message should reference Bandwidth Build, got %q", msg) + } + if strings.Contains(msg, "Numbers role") { + t.Errorf("Build-account message should not point users at the Numbers role, got %q", msg) + } + // Must preserve the underlying APIError so ExitCodeForError can read it. + var unwrapped *api.APIError + if !errors.As(err, &unwrapped) || unwrapped.StatusCode != 403 { + t.Errorf("wrapped error should unwrap to APIError 403") + } +} + func TestWrapTNsError_NonAPIError(t *testing.T) { - err := wrapTNsError(errors.New("network down"), "9901409") + err := wrapTNsError(errors.New("network down"), "9901409", false) if !strings.Contains(err.Error(), "network down") { t.Errorf("should pass through non-API error, got %q", err.Error()) } @@ -158,8 +175,8 @@ func TestWrapTNsError_NonAPIError(t *testing.T) { func TestWrapTNsError_500(t *testing.T) { // Non-403 API errors should pass through without the 403-specific message. apiErr := &api.APIError{StatusCode: 500, Body: "server broke"} - err := wrapTNsError(apiErr, "9901409") - if strings.Contains(err.Error(), "Build credentials") { + err := wrapTNsError(apiErr, "9901409", false) + if strings.Contains(err.Error(), "Numbers role") { t.Errorf("500 should not get the 403 message, got %q", err.Error()) } } diff --git a/cmd/number/search.go b/cmd/number/search.go index df0d43a..3f045be 100644 --- a/cmd/number/search.go +++ b/cmd/number/search.go @@ -50,7 +50,7 @@ func runSearch(cmd *cobra.Command, args []string) error { var result interface{} path := fmt.Sprintf("/accounts/%s/availableNumbers?%s", acctID, q.Encode()) if err := client.Get(path, &result); err != nil { - return fmt.Errorf("searching available numbers: %w", err) + return cmdutil.Wrap403(err, "searching available numbers", "Numbers") } format, plain := cmdutil.OutputFlags(cmd) diff --git a/cmd/shortcode/list.go b/cmd/shortcode/list.go index aec89d5..74b0e0d 100644 --- a/cmd/shortcode/list.go +++ b/cmd/shortcode/list.go @@ -50,8 +50,8 @@ func runList(cmd *cobra.Command, args []string) error { func shortcodeError(err error) error { if apiErr, ok := err.(*api.APIError); ok && apiErr.StatusCode == 403 { - return fmt.Errorf("access denied — your credentials may not have short code access.\n"+ - "Contact your Bandwidth account manager to verify") + return cmdutil.NewFeatureLimit("access denied — your credentials may not have short code access.\n"+ + "Contact your Bandwidth account manager to verify", err) } return fmt.Errorf("listing short codes: %w", err) } diff --git a/cmd/site/list.go b/cmd/site/list.go index adece37..b0c844a 100644 --- a/cmd/site/list.go +++ b/cmd/site/list.go @@ -27,7 +27,7 @@ func runList(cmd *cobra.Command, args []string) error { var result interface{} if err := client.Get(fmt.Sprintf("/accounts/%s/sites", acctID), &result); err != nil { - return fmt.Errorf("listing sub-accounts: %w", err) + return cmdutil.Wrap403(err, "listing sub-accounts", "Sub-Accounts") } format, plain := cmdutil.OutputFlags(cmd) diff --git a/cmd/tendlc/helpers.go b/cmd/tendlc/helpers.go index 6ade771..3a46063 100644 --- a/cmd/tendlc/helpers.go +++ b/cmd/tendlc/helpers.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" ) // roleGateError wraps a 403 API error with a targeted message based on the @@ -13,6 +14,9 @@ import ( // - "import customer is not enabled" — account is a direct (not import) customer // - "is not enabled on account" — campaign management feature disabled // - "does not have access rights" — credential lacks the Campaign Management role +// +// Returns a FeatureLimitError so ExitCodeForError maps these to exit 4 +// (escalate to user) rather than exit 2 (re-auth). func roleGateError(err error, roleName string) error { apiErr, ok := err.(*api.APIError) if !ok || apiErr.StatusCode != 403 { @@ -23,30 +27,30 @@ func roleGateError(err error, roleName string) error { switch { case strings.Contains(body, "not enabled for the Registration Center"): - return fmt.Errorf("your account is not enabled for the Registration Center.\n"+ - "Contact your Bandwidth account manager to enable the Registration Center feature") + return cmdutil.NewFeatureLimit("your account is not enabled for the Registration Center.\n"+ + "Contact your Bandwidth account manager to enable the Registration Center feature", err) case strings.Contains(body, "import customer is not enabled"): - return fmt.Errorf("these commands are for customers who register campaigns through TCR and import\n"+ + return cmdutil.NewFeatureLimit("these commands are for customers who register campaigns through TCR and import\n"+ "them to Bandwidth. Direct campaign registration through the CLI is coming mid-2026.\n"+ - "In the meantime, use the Bandwidth App or the existing Campaign Management API") + "In the meantime, use the Bandwidth App or the existing Campaign Management API", err) case strings.Contains(body, "direct customer is not enabled"): - return fmt.Errorf("these commands are for customers who register campaigns directly through Bandwidth.\n"+ + return cmdutil.NewFeatureLimit("these commands are for customers who register campaigns directly through Bandwidth.\n"+ "Your account is set up as an import customer (campaigns registered through TCR).\n"+ - "Use the import-specific endpoints or contact your Bandwidth account manager") + "Use the import-specific endpoints or contact your Bandwidth account manager", err) case strings.Contains(body, "is not enabled on account"): - return fmt.Errorf("10DLC campaign management is not enabled on this account.\n"+ - "Contact your Bandwidth account manager to enable messaging and campaign management") + return cmdutil.NewFeatureLimit("10DLC campaign management is not enabled on this account.\n"+ + "Contact your Bandwidth account manager to enable messaging and campaign management", err) case strings.Contains(body, "does not have access rights"): - return fmt.Errorf("your credentials don't have the %s role.\n"+ - "Contact your Bandwidth account manager to assign the role to your API user", roleName) + return cmdutil.NewFeatureLimit(fmt.Sprintf("your credentials don't have the %s role.\n"+ + "Contact your Bandwidth account manager to assign the role to your API user", roleName), err) default: - return fmt.Errorf("access denied (403): %s\n"+ - "Contact your Bandwidth account manager to check your account configuration", body) + return cmdutil.NewFeatureLimit(fmt.Sprintf("access denied (403): %s\n"+ + "Contact your Bandwidth account manager to check your account configuration", body), err) } } diff --git a/cmd/tfv/get.go b/cmd/tfv/get.go index 0fee6c9..4e8639d 100644 --- a/cmd/tfv/get.go +++ b/cmd/tfv/get.go @@ -53,8 +53,8 @@ func tfvError(err error, number string) error { } switch apiErr.StatusCode { case 403: - return fmt.Errorf("access denied — your credentials don't have the TFV role.\n"+ - "Contact your Bandwidth account manager to enable it") + return cmdutil.NewFeatureLimit("access denied — your credentials don't have the TFV role.\n"+ + "Contact your Bandwidth account manager to enable it", err) case 404: return fmt.Errorf("no verification request found for %s — submit one with: band tfv submit %s", number, number) diff --git a/cmd/tfv/submit.go b/cmd/tfv/submit.go index ccd32b3..daf5bd3 100644 --- a/cmd/tfv/submit.go +++ b/cmd/tfv/submit.go @@ -136,8 +136,8 @@ func runSubmit(cmd *cobra.Command, args []string) error { if apiErr, ok := err.(*api.APIError); ok { switch apiErr.StatusCode { case 403: - return fmt.Errorf("access denied — your credentials don't have the TFV role.\n"+ - "Contact your Bandwidth account manager to enable it") + return cmdutil.NewFeatureLimit("access denied — your credentials don't have the TFV role.\n"+ + "Contact your Bandwidth account manager to enable it", err) case 400: return fmt.Errorf("validation error: %s", apiErr.Body) } diff --git a/cmd/vcp/list.go b/cmd/vcp/list.go index fc557f0..ea2f1bd 100644 --- a/cmd/vcp/list.go +++ b/cmd/vcp/list.go @@ -27,7 +27,7 @@ func runList(cmd *cobra.Command, args []string) error { var result interface{} if err := client.Get(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), &result); err != nil { - return fmt.Errorf("listing VCPs: %w", err) + return cmdutil.Wrap403(err, "listing VCPs", "VCP") } format, plain := cmdutil.OutputFlags(cmd) diff --git a/internal/cmdutil/exitcodes.go b/internal/cmdutil/exitcodes.go index 8e83b89..09c8eca 100644 --- a/internal/cmdutil/exitcodes.go +++ b/internal/cmdutil/exitcodes.go @@ -15,23 +15,36 @@ const ( ExitConflict = 4 ExitTimeout = 5 ExitFlagError = 6 + ExitRateLimit = 7 ) // ExitCodeForError maps an error to the appropriate exit code. -// API errors are mapped by HTTP status code; all other errors get ExitGeneral. +// FeatureLimitError takes precedence over the raw API status code so a +// 403 caused by a plan/role limit maps to ExitConflict (4) rather than +// ExitAuth (2) — agents can then distinguish "stop, escalate" from +// "re-auth or retry." +// All other errors fall back to status-code mapping, then ExitGeneral. func ExitCodeForError(err error) int { if err == nil { return ExitOK } + var fle *FeatureLimitError + if errors.As(err, &fle) { + return ExitConflict + } var apiErr *api.APIError if errors.As(err, &apiErr) { switch apiErr.StatusCode { case 401, 403: return ExitAuth + case 402: + return ExitConflict case 404: return ExitNotFound case 409: return ExitConflict + case 429: + return ExitRateLimit } } return ExitGeneral diff --git a/internal/cmdutil/exitcodes_test.go b/internal/cmdutil/exitcodes_test.go new file mode 100644 index 0000000..d22970e --- /dev/null +++ b/internal/cmdutil/exitcodes_test.go @@ -0,0 +1,39 @@ +package cmdutil + +import ( + "errors" + "fmt" + "testing" + + "github.com/Bandwidth/cli/internal/api" +) + +func TestExitCodeForError(t *testing.T) { + tests := []struct { + name string + err error + want int + }{ + {"nil error", nil, ExitOK}, + {"plain error", errors.New("boom"), ExitGeneral}, + {"401", &api.APIError{StatusCode: 401}, ExitAuth}, + {"403", &api.APIError{StatusCode: 403}, ExitAuth}, + {"402 payment required", &api.APIError{StatusCode: 402, Body: "insufficient credits"}, ExitConflict}, + {"404", &api.APIError{StatusCode: 404}, ExitNotFound}, + {"409", &api.APIError{StatusCode: 409}, ExitConflict}, + {"429 rate limited", &api.APIError{StatusCode: 429}, ExitRateLimit}, + {"500", &api.APIError{StatusCode: 500}, ExitGeneral}, + {"feature limit wraps 403", NewFeatureLimit("nope", &api.APIError{StatusCode: 403}), ExitConflict}, + {"feature limit precedence beats raw 401", NewFeatureLimit("nope", &api.APIError{StatusCode: 401}), ExitConflict}, + {"wrapped 429 keeps rate limit", fmt.Errorf("wrap: %w", &api.APIError{StatusCode: 429}), ExitRateLimit}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExitCodeForError(tt.err) + if got != tt.want { + t.Errorf("ExitCodeForError(%v) = %d, want %d", tt.err, got, tt.want) + } + }) + } +} diff --git a/internal/cmdutil/featurelimit.go b/internal/cmdutil/featurelimit.go new file mode 100644 index 0000000..c0bd800 --- /dev/null +++ b/internal/cmdutil/featurelimit.go @@ -0,0 +1,87 @@ +package cmdutil + +import ( + "errors" + "fmt" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/config" +) + +// FeatureLimitError indicates that a command failed because the active +// account or credential lacks the role/feature needed to complete it. +// It is mapped to ExitConflict (4) by ExitCodeForError, so an agent can +// branch on "stop and tell the user" without conflating these failures +// with true auth errors (exit 2). +// +// Wraps the underlying error so errors.As(err, &apiErr) still finds the +// original *api.APIError when callers want the raw status code or body. +type FeatureLimitError struct { + msg string + cause error +} + +func (e *FeatureLimitError) Error() string { return e.msg } +func (e *FeatureLimitError) Unwrap() error { return e.cause } + +// NewFeatureLimit wraps a richer, endpoint-specific message in the typed +// error. Use this from existing wrappers that already produce tailored +// guidance for a 403 (e.g. tendlc, tfv, shortcode) so the exit code +// becomes 4 without changing the displayed text. +func NewFeatureLimit(msg string, cause error) error { + return &FeatureLimitError{msg: msg, cause: cause} +} + +// Wrap403 inspects err and, when it is a 403, returns a FeatureLimitError +// shaped to the active profile. +// +// On Bandwidth Build accounts, the message points at the plan limit and +// the upgrade path. On other accounts, it tells the user which role to +// request from their Bandwidth account manager. +// +// `feature` is a human noun phrase describing what the user was trying +// to do (e.g. "VCPs", "phone number search"). `role` is the role to +// suggest for non-Build users; pass "" if unknown. +// +// Non-403 errors pass through as `: ` so they retain their +// original status code for ExitCodeForError to interpret. +func Wrap403(err error, feature, role string) error { + var apiErr *api.APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != 403 { + return fmt.Errorf("%s: %w", feature, err) + } + + if ActiveBuild() { + return NewFeatureLimit(fmt.Sprintf("%s: Bandwidth Build accounts are voice-only — this requires a full Bandwidth account.\n"+ + "Talk to an expert: https://www.bandwidth.com/talk-to-an-expert/", + feature), err) + } + + if role != "" { + return NewFeatureLimit(fmt.Sprintf("%s: credential lacks the %s role.\n"+ + "Contact your Bandwidth account manager to assign this role.", + feature, role), err) + } + return NewFeatureLimit(fmt.Sprintf("%s: credential lacks the required role for this operation.\n"+ + "Contact your Bandwidth account manager.", feature), err) +} + +// ActiveBuild reports whether the active profile is a Bandwidth Build +// account. Returns false on any config-load failure — best-effort, used +// only to shape error messages. +func ActiveBuild() bool { + p := loadActiveProfile() + return p != nil && p.Build +} + +func loadActiveProfile() *config.Profile { + path, err := config.DefaultPath() + if err != nil { + return nil + } + cfg, err := config.Load(path) + if err != nil { + return nil + } + return cfg.ActiveProfileConfig() +} diff --git a/internal/config/config.go b/internal/config/config.go index 73b3426..e84307b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,14 @@ type Profile struct { AccountID string `json:"account_id,omitempty"` Accounts []string `json:"accounts,omitempty"` Environment string `json:"environment,omitempty"` // prod, test + + // Roles lists the JWT-granted role names on this credential. Used to + // gate commands locally and produce capability hints in `auth status`. + Roles []string `json:"roles,omitempty"` + + // Build is true when the credential is for a Bandwidth Build account + // (voice-only, credit-based). + Build bool `json:"build,omitempty"` } // Config holds the CLI configuration values persisted to ~/.band/config.json. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index efbe492..e98ac68 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -231,6 +231,35 @@ func TestProfileSaveAndLoad(t *testing.T) { } } +func TestProfileRolesAndBuildRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + cfg := &Config{Format: "json"} + cfg.SetProfile("default", &Profile{ + ClientID: "build-id", + Roles: []string{"HttpVoice", "HTTP Application Management"}, + Build: true, + }) + + if err := Save(path, cfg); err != nil { + t.Fatal(err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatal(err) + } + + p := loaded.Profiles["default"] + if !p.Build { + t.Errorf("Build = false, want true") + } + if len(p.Roles) != 2 || p.Roles[0] != "HttpVoice" { + t.Errorf("Roles = %v, want [HttpVoice, HTTP Application Management]", p.Roles) + } +} + func TestHasMultipleEnvironments(t *testing.T) { tests := []struct { name string