Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
be83b65
feat(sip): add digest hash, password generation, and validation primi…
kshahbw Jul 27, 2026
bf3ea7f
feat(api): add response-aware request primitive and same-origin redir…
kshahbw Jul 27, 2026
344e624
test(api): cover redirect wiring per constructor via httpClient.Do()
kshahbw Jul 27, 2026
eb10d90
feat(output): redact SIP digest hashes from structured output and raw…
kshahbw Jul 27, 2026
c402f53
fix(output): harden hash regex for attributes; document RedactSecrets…
kshahbw Jul 27, 2026
9197edc
feat(sip): add typed XML models and SIP realm/credential service
kshahbw Jul 27, 2026
73e569c
fix(sip): map faults onto exit codes, fault 2xx envelopes, fix hash/c…
kshahbw Jul 27, 2026
f09dc91
feat(cmdutil): add ExitSecretUnavailable (8) for unrecoverable creden…
kshahbw Jul 27, 2026
c670932
feat(sip): add band sip realm create/list/get/update/delete
kshahbw Jul 27, 2026
2a39f31
fix(sip): preserve exit-code signal in faultExit for 33006/12666/2302…
kshahbw Jul 27, 2026
aa89f66
feat(sip): add band sip credential create/rotate/list/get/delete
kshahbw Jul 27, 2026
6a4d46a
fix(sip): harden credential reconciliation logic per code review
kshahbw Jul 28, 2026
c997684
test(sip): cover the two untested generated-password-loss branches
kshahbw Jul 28, 2026
b6ea830
feat(vcp): add origination route-plan flags with replacement guard
kshahbw Jul 28, 2026
0f7de87
fix(vcp): close route-plan canonicalization gap and route-name no-op
kshahbw Jul 28, 2026
6a86a30
fix(vcp): fold --replace-routes override into the guard function
kshahbw Jul 28, 2026
429583d
feat(sip): add band sip status probe and tri-state sip capability
kshahbw Jul 29, 2026
c22549b
fix(sip): emit probe_failed JSON on rate-limit/server-error, test has…
kshahbw Jul 29, 2026
1248511
test(sip): lock the --plain output contract for realm/credential comm…
kshahbw Jul 29, 2026
f7ed827
docs(sip): document provisioning chain, exit 8, and credential recove…
kshahbw Jul 29, 2026
e0df739
docs(sip): fix inaccurate error citation and incomplete exit-8 recove…
kshahbw Jul 29, 2026
61691e1
fix(sip,vcp): make the exit-code taxonomy match meaning, not HTTP status
kshahbw Jul 29, 2026
24908be
fix(sip,vcp): close the write-once-secret and route-plan-parsing gaps
kshahbw Jul 30, 2026
30cffa6
docs(sip): document the UUID check, canonical delete ID, and exit-8 w…
kshahbw Jul 30, 2026
875dc27
docs(sip): explain why MD5 is protocol-mandated for SIP digest hashes
kshahbw Jul 30, 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
173 changes: 172 additions & 1 deletion AGENTS.md

Large diffs are not rendered by default.

23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,11 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f
| Command | What it does |
|---------|-------------|
| `band vcp create` | Create a VCP |
| `band vcp create --route-endpoint <value> --route-endpoint-type <TN\|SIP\|IP_V4\|FQDN>` | Create a VCP with an origination route |
| `band vcp list` | List all VCPs |
| `band vcp get <id>` | Get VCP details |
| `band vcp update <id>` | Update a VCP (name, description, linked app) |
| `band vcp update <vcp-id> --route-endpoint <value> --route-endpoint-type FQDN --replace-routes` | Replace a VCP's origination route plan |
| `band vcp delete <id>` | Delete a VCP |
| `band vcp assign <id> <number...>` | Assign (or move) numbers to a VCP |
| `band vcp numbers <id>` | List numbers on a VCP |
Expand Down Expand Up @@ -463,6 +465,22 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f
| `band tnoption get <id>` | Check the status of a TN Option Order |
| `band tnoption list` | List TN Option Orders (filter by `--status`, `--tn`) |

### SIP trunk authentication

| Command | What it does |
|---------|-------------|
| `band sip realm create --name <name> --default=<bool>` | Create a SIP realm (`--description`, `--if-not-exists`; async — add `--wait` and optionally `--timeout <seconds>`) |
| `band sip realm list` | List realms |
| `band sip realm get <realm-id-or-name>` | Get one realm, including its FQDN |
| `band sip realm update <realm-id-or-name>` | Update a realm: `--default=true` promotes it to the account default, `--description <text>` replaces its description. Pass either or both; omitted fields are preserved |
| `band sip realm delete <realm-id-or-name>` | Delete a realm (async — add `--wait` and optionally `--timeout <seconds>` to confirm it's actually gone). The argument may be an ID, short name, or FQDN, but the returned `id` is always the resolved numeric realm ID — resolving it costs one extra GET |
| `band sip credential create --realm <realm> --username <user>` | Create a credential (`--password-stdin`, `--password-file`, or `--generate-password`; optional `--app-id` to bind it to a voice app — it must be a UUID, checked before any HTTP request; `--if-not-exists` for idempotent retries). With `--generate-password`, `password` is the first key in the JSON output so a truncated write still delivers it |
| `band sip credential rotate <credential-id> --realm <realm>` | Rotate a credential's password (ID is preserved). With `--generate-password`, `password` is the first key in the JSON output |
| `band sip credential list --realm <realm>` | List a realm's credentials (pagination is not implemented — a full 500-credential page warns on stderr that the list may be truncated) |
| `band sip credential get <credential-id> --realm <realm>` | Get one credential |
| `band sip credential delete <credential-id> --realm <realm>` | Delete a credential |
| `band sip status` | Probe whether this account can use SIP provisioning (resolves the `unknown` capability from `band auth status`) |

### Other

| Command | What it does |
Expand Down Expand Up @@ -533,6 +551,9 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f
| 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) |
| 8 | A resource exists but its secret cannot be recovered — `sip credential create --if-not-exists --generate-password` against an existing credential, a generated-password write whose response was lost, or a generated password that could not be written to stdout (full pipe, closed pipe, or short write) *after* the API write already succeeded. See below for recovery. |

**Recovering from exit 8.** The resource exists; only the secret is gone. If you don't have the credential ID (the error message says so when it doesn't know it), find it with `band sip credential list --realm <realm> --plain`, then run `band sip credential rotate <credential-id> --realm <realm> --generate-password`. Rotating preserves the credential ID, so SIP peers referencing it keep working. Note that the generated `password` is written as the **first** key of the JSON object precisely so a write that gets cut short still delivers the one copy of the secret.

---

Expand Down Expand Up @@ -567,7 +588,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/feature limit, 5 timeout, 7 rate limit. 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, 8 secret unavailable. 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).
Expand Down
54 changes: 54 additions & 0 deletions cmd/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,60 @@ func TestParseJWTClaimsInvalidPayload(t *testing.T) {
}
}

func TestSIPCapability(t *testing.T) {
// capabilities stays map[string]bool — a tri-state cannot live inside it,
// so SIP is reported as a separate typed object.
got := sipCapability(true)
if got["status"] != "unknown" || got["reason"] != "role_present_not_probed" {
t.Errorf("sipCapability(true) = %v, want unknown/role_present_not_probed", got)
}
got = sipCapability(false)
if got["status"] != "unavailable" || got["reason"] != "role_absent" {
t.Errorf("sipCapability(false) = %v, want unavailable/role_absent", got)
}
}

// TestHasRole guards the matcher that decides whether sipCapability reports
// role_absent vs. role_present_not_probed. It mirrors TestCapabilities'
// fixture style, using the exact "SIP Credentials" string as it appears in
// the JWT roles array — a near-miss in casing or spacing here would silently
// report role_absent for an account that genuinely holds the role.
func TestHasRole(t *testing.T) {
tests := []struct {
name string
roles []string
want bool
}{
{
name: "exact role string as it appears in the JWT",
roles: []string{"SIP Credentials"},
want: true,
},
{
name: "realistic full role slice with SIP present",
roles: []string{"HTTP Application Management", "HttpVoice", "SIP Credentials", "brtcAccessRole"},
want: true,
},
{
name: "realistic full role slice without SIP",
roles: []string{"HTTP Application Management", "HttpVoice", "brtcAccessRole"},
want: false,
},
{
name: "empty roles",
roles: nil,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hasRole(tt.roles, "sip credentials"); got != tt.want {
t.Errorf("hasRole(%v, %q) = %v, want %v", tt.roles, "sip credentials", got, tt.want)
}
})
}
}

// 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: 46 additions & 1 deletion cmd/auth/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ type statusJSON struct {
Build bool `json:"build,omitempty"`
Roles []string `json:"roles,omitempty"`
Capabilities map[string]bool `json:"capabilities,omitempty"`
Error string `json:"error,omitempty"`
// 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"`
}

func runStatus(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -88,6 +92,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
Build: p.Build,
Roles: p.Roles,
Capabilities: Capabilities(p.Roles),
SIP: sipCapability(hasRole(p.Roles, "sip credentials")),
}
if keychainErr != nil {
out.Error = "credentials not found in keychain"
Expand Down Expand Up @@ -122,6 +127,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
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("SIP: %s\n", sipSummary(sipCapability(hasRole(p.Roles, "sip credentials"))))
if env != "prod" || cfg.HasMultipleEnvironments() {
fmt.Printf("Environment: %s\n", env)
}
Expand Down Expand Up @@ -179,6 +185,45 @@ func Capabilities(roles []string) map[string]bool {
return caps
}

// hasRole reports whether any role string in roles contains substr,
// case-insensitively — the same matching style Capabilities uses.
func hasRole(roles []string, substr string) bool {
for _, r := range roles {
if strings.Contains(strings.ToLower(r), substr) {
return true
}
}
return false
}

// sipCapability reports SIP provisioning availability as a tri-state. SIP needs
// both the "SIP Credentials" role and account-level SipCredentialSettings, and
// only the role is knowable offline — so a boolean would be misleading.
// Reasons are stable identifiers, not prose. The full set, across this offline
// derivation and the `band sip status` probe: role_absent,
// role_present_not_probed, probe_succeeded, account_not_enabled, probe_failed.
// This function only ever emits the first two — it stays offline.
func sipCapability(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"}
}

// sipSummary renders the offline SIP tri-state for the human-readable auth
// status output. reason values are internal identifiers (see sipCapability);
// only this function turns them into prose.
func sipSummary(sip map[string]string) string {
switch sip["reason"] {
case "role_absent":
return ui.Muted("not available (missing SIP Credentials role)")
case "role_present_not_probed":
return ui.Muted("unknown — run 'band sip status' to check")
default:
return sip["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
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
quickstartcmd "github.com/Bandwidth/cli/cmd/quickstart"
recordingcmd "github.com/Bandwidth/cli/cmd/recording"
shortcodecmd "github.com/Bandwidth/cli/cmd/shortcode"
sipcmd "github.com/Bandwidth/cli/cmd/sip"
sitecmd "github.com/Bandwidth/cli/cmd/site"
tendlccmd "github.com/Bandwidth/cli/cmd/tendlc"
tfvcmd "github.com/Bandwidth/cli/cmd/tfv"
Expand Down Expand Up @@ -95,6 +96,7 @@ func init() {
rootCmd.AddCommand(authcmd.Cmd)
rootCmd.AddCommand(accountcmd.Cmd)
rootCmd.AddCommand(sitecmd.Cmd)
rootCmd.AddCommand(sipcmd.Cmd)
rootCmd.AddCommand(locationcmd.Cmd)
rootCmd.AddCommand(appcmd.Cmd)
rootCmd.AddCommand(numbercmd.Cmd)
Expand Down
26 changes: 26 additions & 0 deletions cmd/sip/credential.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package sip

import "github.com/spf13/cobra"

func init() { Cmd.AddCommand(credentialCmd) }

var credentialCmd = &cobra.Command{
Use: "credential",
Aliases: []string{"cred"},
Short: "Manage SIP digest credentials",
Long: "Manage the SIP digest credentials a peer uses to authenticate to a Bandwidth realm. Bandwidth stores only MD5 hashes; the CLI computes them, so passwords are never sent to the API.",
}

// credentialFlags are shared by create and rotate.
type credentialFlags struct {
realm string
stdin bool
file string
generate bool
}

func addPasswordFlags(cmd *cobra.Command, f *credentialFlags) {
cmd.Flags().BoolVar(&f.stdin, "password-stdin", false, "Read the password from stdin")
cmd.Flags().StringVar(&f.file, "password-file", "", "Read the password from a file")
cmd.Flags().BoolVar(&f.generate, "generate-password", false, "Generate a random password and print it once")
}
Loading