Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d9591b6
feat(cmdutil): make ExitFlagError reachable via typed FlagError
kshahbw Aug 13, 2026
d42c238
test(cmdutil): add competing error to FlagError precedence test
kshahbw Aug 13, 2026
fd94548
feat(api): capture response headers on APIError and add RetryAfter
kshahbw Aug 13, 2026
48b08cc
docs(api): narrow APIError.Header comment to what the codebase guaran…
kshahbw Aug 13, 2026
aa62c42
feat(cmdutil): make Poll cancellable via optional PollConfig.Context
kshahbw Aug 13, 2026
877e408
fix(cmdutil): drop dead timer-drain idiom in Poll, fix vacuous test a…
kshahbw Aug 13, 2026
31fb4a1
feat(tendlc): add deepObject query encoding for list filters
kshahbw Aug 13, 2026
8ed4b6e
docs(tendlc): document duplicate filter behavior and add test coverage
kshahbw Aug 13, 2026
a8add5f
feat(tendlc): add response envelope and pagination parsing
kshahbw Aug 13, 2026
b4856f1
fix(tendlc): fail closed on nil envelope data, clarify Truncated cont…
kshahbw Aug 13, 2026
33bb5ec
feat(tendlc): add service with brand and campaign read methods
kshahbw Aug 13, 2026
9850c02
test(tendlc): lock error type/status contract and cover account-ID es…
kshahbw Aug 13, 2026
42ad330
feat(customerprofile): add service with list and get
kshahbw Aug 13, 2026
2afd4a9
refactor(api): move response envelope and query encoding out of tendlc
kshahbw Aug 13, 2026
7b1adec
feat(auth): add tendlc tri-state and customer_profiles capabilities
kshahbw Aug 13, 2026
c055054
test(auth): cover tendlc/customer_profiles wiring against live role s…
kshahbw Aug 13, 2026
78df52f
feat(tendlc): add status command probing Registration Center access
kshahbw Aug 13, 2026
1a2f20d
fix(tendlc): always emit a stable status result on probe failure
kshahbw Aug 13, 2026
a246c8c
docs(agents): document the 10DLC capability tri-state and status probe
kshahbw Aug 13, 2026
810e198
fix(tendlc): document status reason codes, guard empty flag error, co…
kshahbw Aug 13, 2026
3058feb
fix(auth): derive tendlc tri-state from the campaign_management capab…
kshahbw Aug 14, 2026
d4de65e
Merge branch 'main' into feat/reg-center-10dlc-direct
kshahbw Aug 14, 2026
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
37 changes: 37 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,43 @@ This issues one cheap `GET /realms` call. A `200` reports `available`/`probe_suc

Important: `band sip status` **does not persist** its result anywhere. Run it again any time you need a fresh answer, and don't expect `band auth status` to start reporting anything other than `unknown` for a role-holding credential — that command stays fully offline by design.

#### 10DLC capability (tri-state, not boolean)

`band auth status --plain` reports `tendlc` as `{"status":..., "reason":...}` rather than a
boolean, for the same reason as SIP: access needs both the Campaign Management role and the
account-level Registration Center feature, and only the role is knowable offline.

- `unavailable` / `role_absent` — the credential lacks the Campaign Management role.
- `unknown` / `role_present_not_probed` — run `band tendlc status` to resolve.

`campaign_management` remains a plain boolean meaning "the credential holds the role", and
`customer_profiles` is a separate capability — the Customer Profiles Access role is distinct.

```bash
band tendlc status --plain
# → {"access":"available","mode":{"reason":"not_discoverable","status":"unknown"},"reason":"probe_succeeded"}
```

`band tendlc status` can emit any of these `reason` values. Every 403-derived result
exits **0** — the probe answered the question, even when the answer is negative — so
there is no stderr message to fall back on; this table is the only authoritative place
to learn what to do next. `probe_failed` is the sole exception: the probe couldn't
answer at all, so it exits non-zero and is the only reason worth retrying.

| `reason` | `access` | Exit code | Next action |
|---|---|---|---|
| `probe_succeeded` | `available` | 0 | Registration Center access confirmed. Proceed with `band tendlc` commands. |
| `registration_center_not_enabled` | `unavailable` | 0 | Account doesn't have the Registration Center feature. Escalate to your Bandwidth account manager to enable it — do not retry. |
| `campaign_management_not_enabled` | `unavailable` | 0 | Campaign Management/messaging is not enabled on the account. Escalate to your Bandwidth account manager — do not retry. |
| `role_absent` | `unavailable` | 0 | The credential lacks the Campaign Management role. Have an account manager assign the role — do not retry; a retry hits the same 403. |
| `access_denied` | `unavailable` | 0 | A 403 that didn't match any recognized cause. Escalate to your Bandwidth account manager — do not retry. |
| `probe_failed` | `unknown` | non-zero | The probe itself failed (rate limited, 5xx, or a transport error) — it could not answer the question. This is the only reason where retrying makes sense. |

**`mode` is always `unknown`, by design.** An account either registers campaigns *directly* or
*imports* them from TCR — never both — and that is a property of the account's Bandwidth setup,
not something the API exposes. Do not infer it, and do not try one path to see what happens.
If direct-vs-import was not given to you in the task, stop and ask the operator.

### Account Hint

When multiple accounts or profiles are active, commands write a hint to stderr so you know which account is being targeted:
Expand Down
179 changes: 179 additions & 0 deletions cmd/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import (
"path/filepath"
"testing"

"github.com/spf13/cobra"

"github.com/Bandwidth/cli/internal/config"
"github.com/Bandwidth/cli/internal/testutil"
)

func TestCmdStructure(t *testing.T) {
Expand Down Expand Up @@ -211,6 +214,182 @@ func TestHasRole(t *testing.T) {
}
}

func TestTenDLCCapability(t *testing.T) {
got := tendlcCapability(true)
if got["status"] != "unknown" || got["reason"] != "role_present_not_probed" {
t.Errorf("tendlcCapability(true) = %v, want unknown/role_present_not_probed", got)
}
got = tendlcCapability(false)
if got["status"] != "unavailable" || got["reason"] != "role_absent" {
t.Errorf("tendlcCapability(false) = %v, want unavailable/role_absent", got)
}
}

// Customer Profiles Access is a distinct role from campaign_management —
// confirmed on a live credential that carries both.
func TestCustomerProfilesCapabilityIsSeparate(t *testing.T) {
caps := Capabilities([]string{"campaign_management"})
if caps["customer_profiles"] {
t.Error("customer_profiles should be false without the Customer Profiles Access role")
}
if !caps["campaign_management"] {
t.Error("campaign_management should be true")
}

caps = Capabilities([]string{"Customer Profiles Access"})
if !caps["customer_profiles"] {
t.Error("customer_profiles should be true with the role")
}
if caps["campaign_management"] {
t.Error("campaign_management should be false without its own role")
}
}

// liveAccount9901287Roles is the (trimmed) role list observed on a live
// credential for account 9901287 — captured directly rather than invented, so
// tests exercise the tri-state wiring against a realistic role string, not
// just an idealized one. In particular it confirms the role is genuinely
// snake_case "campaign_management" (not the "Campaign Management" display
// string used elsewhere for error messages), and it includes a decoy —
// "specialized customer external tns" — that contains the word "customer" but
// is not a Customer Profiles Access role.
var liveAccount9901287Roles = []string{
"Alerting Insights", "Analytics", "Billing Reports",
"campaign_management", "Configuration", "Customer Profiles Access",
"Disconnect", "E911 Management", "specialized customer external tns",
"HTTP Application Management", "HttpVoice", "Line Features",
"Ordering", "Porting", "Reporting", "Short Code Access",
}

// removeRole returns a copy of roles with every occurrence of target removed.
func removeRole(roles []string, target string) []string {
out := make([]string, 0, len(roles))
for _, r := range roles {
if r != target {
out = append(out, r)
}
}
return out
}

// TestTenDLCWiringAgainstLiveRoles exercises hasRole, tendlcCapability, and
// Capabilities together against the real role strings from
// liveAccount9901287Roles, rather than testing tendlcCapability(bool) in
// isolation. TestTenDLCCapability and TestCustomerProfilesCapabilityIsSeparate
// already cover the functions' own logic; this test guards the wiring itself
// — a future change to hasRole's matching (e.g. adding normalization that
// treats "_" and " " differently, or an accidental typo in the substring
// passed at the call site) could silently make campaign_management always
// evaluate to role_absent while every existing test still passes.
func TestTenDLCWiringAgainstLiveRoles(t *testing.T) {
if !hasRole(liveAccount9901287Roles, "campaign_management") {
t.Fatal("hasRole should find campaign_management in the live role list")
}
got := tendlcCapability(hasRole(liveAccount9901287Roles, "campaign_management"))
if got["status"] != "unknown" || got["reason"] != "role_present_not_probed" {
t.Errorf("tendlcCapability with live roles = %v, want unknown/role_present_not_probed", got)
}
if !Capabilities(liveAccount9901287Roles)["customer_profiles"] {
t.Error("customer_profiles should be true with live roles (Customer Profiles Access is present)")
}

withoutCampaign := removeRole(liveAccount9901287Roles, "campaign_management")
if hasRole(withoutCampaign, "campaign_management") {
t.Fatal("hasRole should not find campaign_management once it's removed from the role list")
}
got = tendlcCapability(hasRole(withoutCampaign, "campaign_management"))
if got["status"] != "unavailable" || got["reason"] != "role_absent" {
t.Errorf("tendlcCapability without campaign_management = %v, want unavailable/role_absent", got)
}

withoutCustomerProfiles := removeRole(liveAccount9901287Roles, "Customer Profiles Access")
if Capabilities(withoutCustomerProfiles)["customer_profiles"] {
t.Error("customer_profiles should be false once Customer Profiles Access is removed from the role list")
}
}

// TestStatusPlainTenDLCAgreesWithCapabilities is an end-to-end regression
// test on `band auth status --plain`'s actual JSON output, not just on the
// helper functions in isolation. The bug lived in the call-site wiring
// inside runStatus (status.go), which fed tendlcCapability a *different*
// signal — hasRole(p.Roles, "campaign_management"), a plain substring match
// on the unnormalized snake_case string — than the one behind
// capabilities.campaign_management, which is Contains(rl, "campaign") over
// the same roles. For the display-form role "Campaign Management" those two
// signals disagree: the boolean matches (it contains "campaign") but the old
// hasRole lookup does not (no role contains the literal substring
// "campaign_management" with an underscore). That produced a single JSON
// document asserting both capabilities.campaign_management=true and
// tendlc={"status":"unavailable","reason":"role_absent"} for the same
// credential. Testing tendlcCapability(caps["campaign_management"]) directly
// would trivially pass by construction regardless of what runStatus itself
// does; only driving runStatus end to end (as done here) actually exercises
// the wiring and would have failed before the fix.
func TestStatusPlainTenDLCAgreesWithCapabilities(t *testing.T) {
tests := []struct {
name string
roles []string
}{
{name: "snake_case role as seen on live account 9901287", roles: []string{"campaign_management"}},
{name: "display-form role", roles: []string{"Campaign Management"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))

cfgPath, err := config.DefaultPath()
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{Format: "json"}
cfg.SetProfile("default", &config.Profile{
ClientID: "id1",
AccountID: "ACCT_A",
Roles: tt.roles,
})
if err := config.Save(cfgPath, cfg); err != nil {
t.Fatal(err)
}

wrap := &cobra.Command{Use: "status", RunE: runStatus}
root := testutil.NewTestRoot(wrap)
root.SetArgs([]string{"status", "--plain"})

out := testutil.CaptureStdout(t, func() {
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
})

var got statusJSON
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("unmarshal output: %v\noutput: %s", err, out)
}

if !got.Capabilities["campaign_management"] {
t.Fatalf("capabilities.campaign_management = false for roles %v, want true", tt.roles)
}
if got.TenDLC["reason"] == "role_absent" {
t.Errorf("capabilities.campaign_management = true but tendlc = %v — the two must agree in one JSON document", got.TenDLC)
}
})
}
}

// TestCustomerProfilesMatcherNotOverBroad guards against loosening the
// customer_profiles matcher from "customer profiles" to just "customer".
// "specialized customer external tns" is a real role from
// liveAccount9901287Roles that contains "customer" but has nothing to do with
// Customer Profiles Access — it must not flip customer_profiles to true.
func TestCustomerProfilesMatcherNotOverBroad(t *testing.T) {
caps := Capabilities([]string{"specialized customer external tns"})
if caps["customer_profiles"] {
t.Error("customer_profiles should be false for 'specialized customer external tns' — it contains 'customer' but is not a Customer Profiles Access role")
}
}

// TestRunSwitch_PersistsTargetIntoActiveProfile guards against the bug where
// switch only updated the legacy top-level cfg.AccountID, leaving the active
// profile's AccountID stale — so subsequent commands continued targeting the
Expand Down
47 changes: 43 additions & 4 deletions cmd/auth/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ type statusJSON struct {
// SIP reports SIP provisioning availability as a tri-state object
// ({"status":..., "reason":...}) rather than a bool inside Capabilities —
// see sipCapability.
SIP map[string]string `json:"sip,omitempty"`
Error string `json:"error,omitempty"`
SIP map[string]string `json:"sip,omitempty"`
// TenDLC reports Registration Center availability as a tri-state, for the
// same reason as SIP — see tendlcCapability.
TenDLC map[string]string `json:"tendlc,omitempty"`
Error string `json:"error,omitempty"`
}

func runStatus(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -82,6 +85,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
_, keychainErr := intauth.GetPassword(p.ClientID)

if plain {
caps := Capabilities(p.Roles)
out := statusJSON{
Authenticated: keychainErr == nil,
Profile: profileName,
Expand All @@ -91,8 +95,9 @@ func runStatus(cmd *cobra.Command, args []string) error {
Environment: env,
Build: p.Build,
Roles: p.Roles,
Capabilities: Capabilities(p.Roles),
Capabilities: caps,
SIP: sipCapability(hasRole(p.Roles, "sip credentials")),
TenDLC: tendlcCapability(caps["campaign_management"]),
}
if keychainErr != nil {
out.Error = "credentials not found in keychain"
Expand Down Expand Up @@ -123,11 +128,13 @@ 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)")
}
caps := Capabilities(p.Roles)
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)))
fmt.Printf("Capable of: %s\n", capabilitySummary(caps))
}
fmt.Printf("SIP: %s\n", sipSummary(sipCapability(hasRole(p.Roles, "sip credentials"))))
fmt.Printf("10DLC: %s\n", tendlcSummary(tendlcCapability(caps["campaign_management"])))
if env != "prod" || cfg.HasMultipleEnvironments() {
fmt.Printf("Environment: %s\n", env)
}
Expand Down Expand Up @@ -156,6 +163,7 @@ func Capabilities(roles []string) map[string]bool {
"numbers": false,
"vcp": false,
"campaign_management": false,
"customer_profiles": false,
"tfv": false,
}
for _, r := range roles {
Expand All @@ -178,6 +186,9 @@ func Capabilities(roles []string) map[string]bool {
if strings.Contains(rl, "campaign") {
caps["campaign_management"] = true
}
if strings.Contains(rl, "customer profiles") {
caps["customer_profiles"] = true
}
if strings.Contains(rl, "tfv") || strings.Contains(rl, "toll-free") || strings.Contains(rl, "tollfree") {
caps["tfv"] = true
}
Expand Down Expand Up @@ -224,6 +235,34 @@ func sipSummary(sip map[string]string) string {
}
}

// tendlcCapability reports 10DLC Registration Center availability as a
// tri-state. Access needs both the Campaign Management role and the
// account-level Registration Center feature; only the role is knowable
// offline, so a boolean would over-promise. Mirrors sipCapability.
//
// Callers must pass caps["campaign_management"] from the same Capabilities()
// call used for the campaign_management boolean, not a separately-matched
// hasRole lookup — otherwise the two can disagree on a single credential
// (e.g. a display-form role string) even though they describe the same fact.
func tendlcCapability(hasRole bool) map[string]string {
if !hasRole {
return map[string]string{"status": "unavailable", "reason": "role_absent"}
}
return map[string]string{"status": "unknown", "reason": "role_present_not_probed"}
}

// tendlcSummary renders the offline tri-state for human-readable output.
func tendlcSummary(t map[string]string) string {
switch t["reason"] {
case "role_absent":
return ui.Muted("not available (missing Campaign Management role)")
case "role_present_not_probed":
return ui.Muted("unknown — run 'band tendlc status' to check")
default:
return t["status"]
}
}

// 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 {
Expand Down
Loading
Loading