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
31 changes: 24 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,27 +389,44 @@ When `--wait` times out (exit code 5), the operation may have succeeded — the

### Build Registration: Create a new account from zero

Use when no credentials exist yet. The CLI submits the registration request; the remaining setup happens in the browser. **An agent cannot complete this flow autonomously** — it requires a human (or an agent with web/phone access) to finish.
Use when no credentials exist yet. The CLI can drive registration and phone
verification end to end; only setting a password still happens in the
browser. **An agent cannot complete the whole flow autonomously** — it can
register and verify the phone number itself, but setting a password (via the
link Bandwidth emails to the registered address) requires a human (or an
agent with email/web access) to finish.

```bash
band account register --phone +15555550100 --email you@example.com --first-name Jane --last-name Doe --accept-tos
# → registration submitted; remaining steps happen outside the CLI:
# 1. Check email for a registration link from Bandwidth
# 2. Enter the OTP code sent via SMS to verify the phone number
# 3. Set a password and enter the OTP code from the email
# 4. Go to Account > API Credentials to generate OAuth2 credentials
# → registration submitted (POST /v1/express/registration)

band account send-code --phone +15555550100 --email you@example.com --delivery-channel sms # or "voice"
# → verification code sent. Choosing "sms" IS the customer's consent to
# receive that one-time code by text — there is no separate flag for it.

# STOP: the code is delivered out-of-band (a text message or phone call to
# the registered number) — an agent cannot read it. Wait for a human to
# supply the real code before running verify; do not fabricate one or reuse
# a code from a different registration. "123456" below is illustrative only.
band account verify --phone +15555550100 --email you@example.com --code 123456
# → phone verified (PHONE_VERIFIED); account provisioning begins. Remaining
# steps happen outside the CLI:
# 1. Check email for a registration link from Bandwidth to set a password
# 2. Go to Account > API Credentials to generate OAuth2 credentials
# → once credentials are available:
band auth login --client-id <id> --client-secret <secret>
band auth status # confirm
```

**`register`'s `--sms-opt-in` is marketing consent, not verification-code delivery consent.** It maps to `promotionalCommsAccepted` — consent to receive marketing/PFT-campaign SMS from Bandwidth, independent of the implicit MFA-delivery consent from choosing `--delivery-channel sms` on `send-code`. It is optional; registration succeeds whether it is set or not.

**Important for agents:** Registration requires accepting the [Bandwidth Build Terms of Service](https://www.bandwidth.com/legal/build-terms-of-service/). Before passing `--accept-tos`, you **must** present the full Terms of Service URL to the user and get their explicit confirmation. Do not accept on the user's behalf without showing them the terms first. The flow should be:

1. Show the user: "Registration requires accepting the Bandwidth Build Terms of Service: https://www.bandwidth.com/legal/build-terms-of-service/"
2. Ask the user to review and confirm they accept
3. Only after confirmation, run the command with `--accept-tos`

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 calling `band account verify`, stop and tell the user they need to check their email to set a password before generating API credentials. Do not attempt to poll or wait for that step — 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` 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.)

Expand Down
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,19 @@ You can sign up for a Bandwidth Build trial account from the CLI:
band account register --phone +15555550100 --email you@example.com --first-name Jane --last-name Doe
```

You'll be prompted to accept the [Bandwidth Build Terms of Service](https://www.bandwidth.com/legal/build-terms-of-service/) before registration proceeds. For scripted usage, pass `--accept-tos`.
You'll be prompted to accept the [Bandwidth Build Terms of Service](https://www.bandwidth.com/legal/build-terms-of-service/) before registration proceeds. For scripted usage, pass `--accept-tos`. Add `--sms-opt-in` if you'd also like to opt in to marketing SMS from Bandwidth (optional).

Then complete setup in your browser:
Then verify your phone number:

1. Check your email for a registration link from Bandwidth
2. Enter the OTP code sent via SMS to verify your phone number
3. Set your password and enter the OTP code from your email
4. Go to **Account > API Credentials** to generate your OAuth2 credentials
```sh
band account send-code --phone +15555550100 --email you@example.com --delivery-channel sms # or "voice" for a phone call
band account verify --phone +15555550100 --email you@example.com --code 123456 # use the code you actually received
```

Then finish setup in your browser:

1. Check your email for a registration link from Bandwidth to set your password
2. Go to **Account > API Credentials** to generate your OAuth2 credentials

Once your credentials are ready, run `band auth login` and you're off.

Expand Down Expand Up @@ -387,7 +392,9 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f

| Command | What it does |
|---------|-------------|
| `band account register` | Register a new Bandwidth account |
| `band account register` | Register a new Bandwidth account (`--sms-opt-in` to opt in to marketing SMS) |
| `band account send-code` | Send (or resend) a phone verification code (`--delivery-channel sms\|voice`, required) |
| `band account verify` | Verify a phone number with the code from `send-code` |

### Applications

Expand Down
45 changes: 43 additions & 2 deletions cmd/account/account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ func TestCmdStructure(t *testing.T) {
for _, c := range Cmd.Commands() {
subs[c.Use] = true
}
if !subs["register"] {
t.Errorf("missing subcommand %q", "register")
for _, want := range []string{"register", "send-code", "verify"} {
if !subs[want] {
t.Errorf("missing subcommand %q", want)
}
}
}

Expand All @@ -31,3 +33,42 @@ func TestRegisterRequiredFlags(t *testing.T) {
}
}
}

func TestRegisterSmsOptInFlagNotRequired(t *testing.T) {
f := registerCmd.Flags().Lookup("sms-opt-in")
if f == nil {
t.Fatal("missing flag \"sms-opt-in\"")
}
if _, ok := f.Annotations["cobra_annotation_bash_completion_one_required_flag"]; ok {
t.Error("flag \"sms-opt-in\" should not be required")
}
}

func TestSendCodeRequiredFlags(t *testing.T) {
// delivery-channel is required, not defaulted: the choice of "sms" is
// itself the customer's MFA-delivery consent, so it must be explicit
// rather than silently assumed when the caller omits the flag.
for _, flag := range []string{"phone", "email", "delivery-channel"} {
f := sendCodeCmd.Flags().Lookup(flag)
if f == nil {
t.Errorf("missing flag %q", flag)
continue
}
if _, ok := f.Annotations["cobra_annotation_bash_completion_one_required_flag"]; !ok {
t.Errorf("flag %q should be required", flag)
}
}
}

func TestVerifyRequiredFlags(t *testing.T) {
for _, flag := range []string{"phone", "email", "code"} {
f := verifyCmd.Flags().Lookup(flag)
if f == nil {
t.Errorf("missing flag %q", flag)
continue
}
if _, ok := f.Annotations["cobra_annotation_bash_completion_one_required_flag"]; !ok {
t.Errorf("flag %q should be required", flag)
}
}
}
158 changes: 158 additions & 0 deletions cmd/account/golden_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package account

import (
"testing"

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

// swapRegistrationClient substitutes registrationClient with fake, restoring it on cleanup; no t.Parallel() on callers (mutates a global, like cmdutil.VoiceClient).
func swapRegistrationClient(t *testing.T, fake *testutil.FakeClient) {
t.Helper()
orig := registrationClient
t.Cleanup(func() { registrationClient = orig })
registrationClient = func(string) (api.Requester, string, error) {
return fake, "", nil
}
}

func TestRegisterPlainOutput(t *testing.T) {
fake := &testutil.FakeClient{PostResult: map[string]interface{}{
"links": []interface{}{},
"data": map[string]interface{}{
"message": "Onboarding request received successfully",
"status": "USER_CREATION_PENDING",
"registrationId": "reg-123",
},
"errors": []interface{}{},
}}
swapRegistrationClient(t, fake)

root := testutil.NewTestRoot(registerCmd)
root.SetArgs([]string{
"register",
"--phone", "+19195551234",
"--email", "user@example.com",
"--first-name", "Jane",
"--last-name", "Doe",
"--accept-tos",
"--sms-opt-in",
"--plain",
})

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

want := "{\n \"message\": \"Onboarding request received successfully\",\n \"registrationId\": \"reg-123\",\n \"status\": \"USER_CREATION_PENDING\"\n}\n"
if out != want {
t.Fatalf("golden mismatch:\n got: %q\nwant: %q", out, want)
}

if fake.PostPath != "/registration" {
t.Errorf("PostPath = %q, want %q", fake.PostPath, "/registration")
}
body, ok := fake.PostBody.(map[string]interface{})
if !ok {
t.Fatalf("PostBody is %T, want map[string]interface{}", fake.PostBody)
}
if body["phoneNumber"] != "+19195551234" || body["email"] != "user@example.com" {
t.Errorf("unexpected phone/email in request body: %+v", body)
}
if body["tosAccepted"] != true {
t.Errorf("tosAccepted = %v, want true", body["tosAccepted"])
}
if body["promotionalCommsAccepted"] != true {
t.Errorf("promotionalCommsAccepted = %v, want true (--sms-opt-in was passed)", body["promotionalCommsAccepted"])
}
}

func TestSendCodePlainOutput(t *testing.T) {
fake := &testutil.FakeClient{PostResult: map[string]interface{}{
"links": []interface{}{},
"data": map[string]interface{}{
"message": "Code successfully sent to +19195551234",
"status": "VERIFICATION_CODE_SENT",
},
"errors": []interface{}{},
}}
swapRegistrationClient(t, fake)

root := testutil.NewTestRoot(sendCodeCmd)
root.SetArgs([]string{
"send-code",
"--phone", "+19195551234",
"--email", "user@example.com",
"--delivery-channel", " Voice ", // normalization: mixed case + whitespace
"--plain",
})

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

want := "{\n \"message\": \"Code successfully sent to +19195551234\",\n \"status\": \"VERIFICATION_CODE_SENT\"\n}\n"
if out != want {
t.Fatalf("golden mismatch:\n got: %q\nwant: %q", out, want)
}

if fake.PostPath != "/registration/code" {
t.Errorf("PostPath = %q, want %q", fake.PostPath, "/registration/code")
}
body, ok := fake.PostBody.(map[string]interface{})
if !ok {
t.Fatalf("PostBody is %T, want map[string]interface{}", fake.PostBody)
}
if body["deliveryChannel"] != "VOICE" {
t.Errorf("deliveryChannel = %v, want normalized %q", body["deliveryChannel"], "VOICE")
}
}

func TestVerifyPlainOutput(t *testing.T) {
fake := &testutil.FakeClient{PostResult: map[string]interface{}{
"links": []interface{}{},
"data": map[string]interface{}{
"message": "+19195551234 successfully verified",
"status": "PHONE_VERIFIED",
"registrationId": "reg-123",
},
"errors": []interface{}{},
}}
swapRegistrationClient(t, fake)

root := testutil.NewTestRoot(verifyCmd)
root.SetArgs([]string{
"verify",
"--phone", "+19195551234",
"--email", "user@example.com",
"--code", "123456",
"--plain",
})

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

want := "{\n \"message\": \"+19195551234 successfully verified\",\n \"registrationId\": \"reg-123\",\n \"status\": \"PHONE_VERIFIED\"\n}\n"
if out != want {
t.Fatalf("golden mismatch:\n got: %q\nwant: %q", out, want)
}

if fake.PostPath != "/registration/code/verify" {
t.Errorf("PostPath = %q, want %q", fake.PostPath, "/registration/code/verify")
}
body, ok := fake.PostBody.(map[string]interface{})
if !ok {
t.Fatalf("PostBody is %T, want map[string]interface{}", fake.PostBody)
}
if body["code"] != "123456" {
t.Errorf("code = %v, want %q", body["code"], "123456")
}
}
54 changes: 36 additions & 18 deletions cmd/account/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,23 @@ var (
registerFirstName string
registerLastName string
registerAcceptTOS bool
registerSmsOptIn bool
)

const tosURL = "https://www.bandwidth.com/legal/build-terms-of-service/"

// registrationClient is a swappable ClientFunc seam for tests (see cmdutil.VoiceClient); accountIDOverride is unused — no account exists yet.
var registrationClient cmdutil.ClientFunc = func(string) (api.Requester, string, error) {
return api.NewClientNoAuth(cmdutil.RegistrationHost()), "", nil
}

func init() {
registerCmd.Flags().StringVar(&registerPhone, "phone", "", "Phone number (required)")
registerCmd.Flags().StringVar(&registerEmail, "email", "", "Email address (required)")
registerCmd.Flags().StringVar(&registerFirstName, "first-name", "", "First name (required)")
registerCmd.Flags().StringVar(&registerLastName, "last-name", "", "Last name (required)")
registerCmd.Flags().BoolVar(&registerAcceptTOS, "accept-tos", false, "Accept the Build Terms of Service (required; use for non-interactive mode)")
registerCmd.Flags().BoolVar(&registerSmsOptIn, "sms-opt-in", false, "Opt in to marketing SMS/communications from Bandwidth (optional; independent of MFA delivery consent)")
_ = registerCmd.MarkFlagRequired("phone")
_ = registerCmd.MarkFlagRequired("email")
_ = registerCmd.MarkFlagRequired("first-name")
Expand All @@ -42,13 +49,18 @@ var registerCmd = &cobra.Command{
Short: "Create a new Bandwidth Build account",
Long: `Creates a new Bandwidth Build account.

After registration, complete account setup in your browser:
1. Check your email for a registration link from Bandwidth
2. Enter the OTP code sent via SMS to verify your phone number
3. Set your password and enter the OTP code from your email
After registration, verify your phone number and complete setup:
1. band account send-code --phone <phone> --email <email> --delivery-channel sms (or "voice")
2. band account verify --phone <phone> --email <email> --code <code-you-received>
3. Check your email for a registration link from Bandwidth to set your password
4. Go to Account > API Credentials to generate OAuth2 credentials
5. Run "band auth login" with those credentials`,
Example: ` band account register --phone +19195551234 --email user@example.com --first-name John --last-name Doe`,
5. Run "band auth login" with those credentials

--sms-opt-in records consent to marketing/PFT-campaign SMS. It is independent
of the MFA delivery consent implied by choosing "sms" as the delivery channel
on "band account send-code" — omit it (or pass --sms-opt-in=false) for no
marketing consent; registration succeeds either way.`,
Example: ` band account register --phone +19195551234 --email user@example.com --first-name John --last-name Doe --sms-opt-in`,
RunE: runRegister,
}

Expand Down Expand Up @@ -83,14 +95,18 @@ func runRegister(cmd *cobra.Command, args []string) error {
return fmt.Errorf("registration cancelled — you must accept the Build Terms of Service to proceed")
}

client := api.NewClientNoAuth("https://api.bandwidth.com/v1/express")
client, _, err := registrationClient("")
if err != nil {
return err
}

reqBody := map[string]interface{}{
"phoneNumber": registerPhone,
"email": registerEmail,
"firstName": registerFirstName,
"lastName": registerLastName,
"tosAccepted": true,
"phoneNumber": registerPhone,
"email": registerEmail,
"firstName": registerFirstName,
"lastName": registerLastName,
"tosAccepted": true,
"promotionalCommsAccepted": registerSmsOptIn,
}

var result interface{}
Expand All @@ -105,12 +121,14 @@ func runRegister(cmd *cobra.Command, args []string) error {

fmt.Fprintln(os.Stderr)
ui.Successf("Registration submitted!")
ui.Headerf("Next steps (complete in your browser):")
ui.Infof("1. Check your email (%s) for a registration link from Bandwidth", registerEmail)
ui.Infof("2. Enter the OTP code sent via SMS to %s", registerPhone)
ui.Infof("3. Set your password and enter the OTP code from your email")
ui.Infof("4. Go to Account > API Credentials to generate your OAuth2 credentials")
ui.Infof("5. Run: band auth login --client-id <id> --client-secret <secret>")
ui.Headerf("Next steps:")
ui.Infof("1. Verify your phone number:")
ui.Infof(" band account send-code --phone %s --email %s --delivery-channel sms", registerPhone, registerEmail)
ui.Infof(" band account verify --phone %s --email %s --code <code-you-received>", registerPhone, registerEmail)
ui.Infof(" (pass --delivery-channel voice on send-code for a phone call instead of a text)")
ui.Infof("2. Check your email (%s) for a registration link from Bandwidth to set your password", registerEmail)
ui.Infof("3. Go to Account > API Credentials to generate your OAuth2 credentials")
ui.Infof("4. Run: band auth login --client-id <id> --client-secret <secret>")

return nil
}
Loading
Loading