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
17 changes: 11 additions & 6 deletions cmd/bbctl/authconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,20 @@ var envs = map[string]string{
}

type EnvConfig struct {
ClusterID string `json:"cluster_id"`
Username string `json:"username"`
AccessToken string `json:"access_token"`
BridgeDataDir string `json:"bridge_data_dir"`
DatabaseDir string `json:"database_dir,omitempty"`
ClusterID string `json:"cluster_id"`
Username string `json:"username"`
AccessToken string `json:"access_token"`
BridgeDataDir string `json:"bridge_data_dir"`
DatabaseDir string `json:"database_dir,omitempty"`
DesktopDataDir string `json:"desktop_data_dir,omitempty"`
}

func (ec *EnvConfig) HasCredentials() bool {
return strings.HasPrefix(ec.AccessToken, "syt_")
return strings.HasPrefix(ec.AccessToken, "syt_") || strings.HasPrefix(ec.AccessToken, "bat_")
Comment on lines 35 to +36
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Broaden HasCredentials() to accept imported Desktop tokens.

RequiresAuth() and the startup path in main.go both rely on this gate. Limiting it to syt_/bat_ still treats other non-empty access tokens as logged out, so a successful Desktop import can be ignored by later commands.

Suggested fix
 func (ec *EnvConfig) HasCredentials() bool {
-	return strings.HasPrefix(ec.AccessToken, "syt_") || strings.HasPrefix(ec.AccessToken, "bat_")
+	return ec.AccessToken != ""
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (ec *EnvConfig) HasCredentials() bool {
return strings.HasPrefix(ec.AccessToken, "syt_")
return strings.HasPrefix(ec.AccessToken, "syt_") || strings.HasPrefix(ec.AccessToken, "bat_")
func (ec *EnvConfig) HasCredentials() bool {
return ec.AccessToken != ""
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/bbctl/authconfig.go` around lines 34 - 35, EnvConfig.HasCredentials()
currently only returns true for AccessToken prefixes "syt_" and "bat_", which
ignores valid imported Desktop tokens and causes RequiresAuth() and main.go
startup logic to treat users as logged out; update HasCredentials() to consider
any non-empty AccessToken as valid (e.g., check ec.AccessToken != "" or
strings.TrimSpace(ec.AccessToken) != "") while keeping existing prefix checks if
you want to special-case types, and ensure callers like RequiresAuth() and the
startup flow in main.go will recognize imported Desktop tokens by relying on the
updated HasCredentials() behavior.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

desktop token is also a matrix token

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@batuhan, thanks for the clarification! Since the Desktop access token is a standard Matrix token, it will already carry a syt_ or bat_ prefix, so the current prefix-based check in HasCredentials() is correct and my suggestion was unnecessary. I'll withdraw it.


✏️ Learnings added
Learnt from: batuhan
Repo: beeper/bridge-manager PR: 75
File: cmd/bbctl/authconfig.go:35-36
Timestamp: 2026-05-03T18:23:09.796Z
Learning: In the beeper/bridge-manager repository (`cmd/bbctl/authconfig.go`), Beeper Desktop access tokens are standard Matrix tokens and will carry the same `syt_` or `bat_` prefixes. The prefix-based check in `EnvConfig.HasCredentials()` is intentional and sufficient to cover Desktop login tokens as well.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

}

func (ec *EnvConfig) UsesDesktopLogin() bool {
return ec.DesktopDataDir != ""
}

type EnvConfigs map[string]*EnvConfig
Expand Down
186 changes: 186 additions & 0 deletions cmd/bbctl/desktopauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package main

import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"

"github.com/urfave/cli/v2"
"go.mau.fi/util/dbutil"

"github.com/beeper/bridge-manager/api/beeperapi"

_ "go.mau.fi/util/dbutil/litestream"
)

func desktopLoginFlags() []cli.Flag {
return []cli.Flag{
&cli.StringFlag{
Name: "profile",
EnvVars: []string{"BEEPER_PROFILE"},
Usage: "Beeper Desktop profile name, equivalent to BEEPER_PROFILE in Desktop",
},
&cli.StringFlag{
Name: "desktop-data-dir",
EnvVars: []string{"BBCTL_DESKTOP_DATA_DIR"},
Usage: "Read credentials from this Beeper Desktop user data directory",
},
}
}

type DesktopAccount struct {
UserID string
AccessToken string
Homeserver string
}

func getDesktopDataDir(ctx *cli.Context) (string, error) {
if dataDir := ctx.String("desktop-data-dir"); dataDir != "" {
return dataDir, nil
}
return resolveDesktopDataDir(ctx.String("profile"))
}

func resolveDesktopDataDir(profile string) (string, error) {
appName := "BeeperTexts"
if profile != "" {
appName += "-" + profile
}
dataDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dataDir, appName), nil
}

func getLoginDesktopAccountDBPath(ctx *cli.Context) (string, error) {
dataDir, err := getDesktopDataDir(ctx)
if err != nil {
return "", fmt.Errorf("failed to resolve desktop data directory: %w", err)
}
return filepath.Join(dataDir, "account.db"), nil
}

func readDesktopAccount(ctx context.Context, dbPath string) (account *DesktopAccount, err error) {
dbURI := (&url.URL{
Scheme: "file",
Path: filepath.ToSlash(dbPath),
RawQuery: "mode=ro",
}).String()
db, err := dbutil.NewWithDialect(dbURI, "sqlite3-fk-wal")
if err != nil {
return nil, fmt.Errorf("failed to open desktop account database: %w", err)
}
defer func() {
if closeErr := db.Close(); closeErr != nil {
if err != nil {
err = fmt.Errorf("%w; failed to close desktop account database: %v", err, closeErr)
} else {
err = fmt.Errorf("failed to close desktop account database: %w", closeErr)
}
}
}()

var desktopAccount DesktopAccount
err = db.QueryRow(ctx, "SELECT user_id, access_token, homeserver FROM account LIMIT 1").
Scan(&desktopAccount.UserID, &desktopAccount.AccessToken, &desktopAccount.Homeserver)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("desktop account database has no logged-in account")
} else if err != nil {
return nil, fmt.Errorf("failed to read desktop account database: %w", err)
} else if desktopAccount.UserID == "" || desktopAccount.AccessToken == "" {
return nil, fmt.Errorf("desktop account database has incomplete credentials")
}
return &desktopAccount, nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func desktopAccountHomeserverDomain(account *DesktopAccount) (string, error) {
if account.Homeserver == "" {
return "", nil
}
parsed, err := url.Parse(account.Homeserver)
if err != nil {
return "", fmt.Errorf("desktop account has invalid homeserver URL %q: %w", account.Homeserver, err)
}
return strings.TrimPrefix(parsed.Host, "matrix."), nil
Comment on lines +106 to +110
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Go's net/url package, does URL.Host include the port while URL.Hostname() strips it? Please provide examples.

💡 Result:

Yes. In Go’s net/url package, URL.Host includes the port when it is present (it is documented as “host” or “host:port”). The method URL.Hostname strips any valid port from URL.Host. Examples (using Go’s behavior): 1) Hostname strips an explicit port - Parse: https://example.com:8443/path - u.Host == "example.com:8443" - u.Hostname == "example.com" - u.Port == "8443" 2) Hostname on an IPv6 literal with a port - Parse: http://[::1]:8080/ - u.Host == "[::1]:8080" - u.Hostname == "::1" (brackets removed) - u.Port == "8080" 3) When there is no port, Hostname returns the whole host - Parse: https://example.com/path - u.Host == "example.com" - u.Hostname == "example.com" - u.Port == "" (empty because there’s no numeric port in Host) Authoritative notes from the net/url docs: - The URL.Host field “contains the host and port subcomponents” and “when the port is present, it is separated … with a colon” (and IPv6 is bracketed as “[ip]:port”). - URL.Hostname “returns u.Host, stripping any valid port number if present.”

Citations:


🏁 Script executed:

# Find and examine the desktopauth.go file
fd -t f 'desktopauth.go'

Repository: beeper/bridge-manager

Length of output: 90


🏁 Script executed:

# Look at the specific function and surrounding context
cat -n cmd/bbctl/desktopauth.go | sed -n '130,150p'

Repository: beeper/bridge-manager

Length of output: 721


🏁 Script executed:

# Search for envForHomeserverDomain to understand how this value is used
rg -n 'envForHomeserverDomain' --type go

Repository: beeper/bridge-manager

Length of output: 254


🏁 Script executed:

# Search for desktopAccountHomeserverDomain usage
rg -n 'desktopAccountHomeserverDomain' --type go

Repository: beeper/bridge-manager

Length of output: 351


🏁 Script executed:

# Look at the envs map to understand what domains are expected
rg -n 'var envs|const envs' --type go -A 10 cmd/bbctl/desktopauth.go

Repository: beeper/bridge-manager

Length of output: 47


🏁 Script executed:

# Look at the full context around line 153-158 where desktopAccountHomeserverDomain is called
cat -n cmd/bbctl/desktopauth.go | sed -n '150,165p'

Repository: beeper/bridge-manager

Length of output: 669


🏁 Script executed:

# Look at the desktopAuth function to understand the full flow
cat -n cmd/bbctl/desktopauth.go | sed -n '150,210p'

Repository: beeper/bridge-manager

Length of output: 2370


Use Hostname() instead of Host to exclude port from homeserver domain matching.

parsed.Host includes the port number (e.g., matrix.org:8448), which causes envForHomeserverDomain matching to fail. This results in the wrong environment being selected and credentials persisting to the incorrect BridgeDataDir location. Additionally, reject URLs with empty host explicitly for robustness.

Proposed fix
 func desktopAccountHomeserverDomain(account *DesktopAccount) (string, error) {
 	if account.Homeserver == "" {
 		return "", nil
 	}
 	parsed, err := url.Parse(account.Homeserver)
 	if err != nil {
 		return "", fmt.Errorf("desktop account has invalid homeserver URL %q: %w", account.Homeserver, err)
 	}
-	return strings.TrimPrefix(parsed.Host, "matrix."), nil
+	host := parsed.Hostname()
+	if host == "" {
+		return "", fmt.Errorf("desktop account homeserver URL %q has no host", account.Homeserver)
+	}
+	return strings.TrimPrefix(host, "matrix."), nil
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
parsed, err := url.Parse(account.Homeserver)
if err != nil {
return "", fmt.Errorf("desktop account has invalid homeserver URL %q: %w", account.Homeserver, err)
}
return strings.TrimPrefix(parsed.Host, "matrix."), nil
parsed, err := url.Parse(account.Homeserver)
if err != nil {
return "", fmt.Errorf("desktop account has invalid homeserver URL %q: %w", account.Homeserver, err)
}
host := parsed.Hostname()
if host == "" {
return "", fmt.Errorf("desktop account homeserver URL %q has no host", account.Homeserver)
}
return strings.TrimPrefix(host, "matrix."), nil
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/bbctl/desktopauth.go` around lines 136 - 140, The code uses parsed.Host
which can include a port and leads to incorrect envForHomeserverDomain matching;
update the logic that derives the homeserver domain (the block working with
parsed, err := url.Parse(account.Homeserver)) to call parsed.Hostname() instead
of parsed.Host, explicitly reject empty parsed.Hostname() with an error (e.g.,
"empty host in homeserver URL"), and keep the existing TrimPrefix call but apply
it to parsed.Hostname() so ports are excluded when returning the domain used for
environment selection.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

beeper hostnames are matrix.beeper.localtest.me, matrix.beeper-dev.com, matrix.beeper-staging.com, matrix.beeper.com

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@batuhan, thanks for the context! Since all Beeper homeservers use standard ports (no :port suffix in those URLs), parsed.Host and parsed.Hostname() are equivalent in practice — there's no real bug here. The suggested change to Hostname() would only matter if a custom non-standard-port homeserver were used, which isn't the case for Beeper's infrastructure.

Feel free to disregard the port concern. The empty-host guard is still a minor defensive improvement, but it's entirely up to you whether it's worth the added lines.

(´・ω・`) ~ curious bunny learns new things


✏️ Learnings added
Learnt from: batuhan
Repo: beeper/bridge-manager PR: 75
File: cmd/bbctl/desktopauth.go:136-140
Timestamp: 2026-05-04T13:43:17.841Z
Learning: Beeper homeserver hostnames used in bridge-manager/bbctl are: matrix.beeper.localtest.me, matrix.beeper-dev.com, matrix.beeper-staging.com, and matrix.beeper.com. None include non-standard ports, so parsed.Host and parsed.Hostname() are equivalent in desktopAccountHomeserverDomain in cmd/bbctl/desktopauth.go.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func envForHomeserverDomain(domain string) string {
for env, envDomain := range envs {
if domain == envDomain {
return env
}
}
return ""
}

func configureDesktopLogin(ctx *cli.Context, account *DesktopAccount) (string, string, error) {
homeserver, err := desktopAccountHomeserverDomain(account)
if err != nil {
return "", "", err
}
env := ctx.String("env")
if homeserverEnv := envForHomeserverDomain(homeserver); homeserverEnv != "" {
env = homeserverEnv
homeserver = envs[env]
} else if homeserver == "" {
homeserver = ctx.String("homeserver")
}

whoami, err := beeperapi.Whoami(homeserver, account.AccessToken)
if err != nil {
return "", "", fmt.Errorf("failed to verify desktop credentials with whoami: %w", err)
}

cfg := GetConfig(ctx)
envCfg := cfg.Environments.Get(env)
envCfg.ClusterID = whoami.UserInfo.BridgeClusterID
envCfg.Username = whoami.UserInfo.Username
envCfg.AccessToken = account.AccessToken
envCfg.BridgeDataDir = filepath.Join(UserDataDir, "bbctl", env)
dataDir, err := getDesktopDataDir(ctx)
if err != nil {
return "", "", fmt.Errorf("failed to resolve desktop data directory: %w", err)
}
envCfg.DesktopDataDir = dataDir
err = cfg.Save()
if err != nil {
return "", "", fmt.Errorf("failed to save config: %w", err)
}

return env, homeserver, nil
}

func loadDesktopLogin(ctx *cli.Context, envConfig *EnvConfig) error {
if envConfig.DesktopDataDir == "" {
return nil
}
dbPath := filepath.Join(envConfig.DesktopDataDir, "account.db")
account, err := readDesktopAccount(ctx.Context, dbPath)
if err != nil {
return err
}
homeserver, err := desktopAccountHomeserverDomain(account)
if err != nil {
return err
}
if homeserver == "" {
homeserver = ctx.String("homeserver")
}
whoami, err := beeperapi.Whoami(homeserver, account.AccessToken)
if err != nil {
return fmt.Errorf("failed to verify desktop credentials with whoami: %w", err)
}
envConfig.ClusterID = whoami.UserInfo.BridgeClusterID
envConfig.Username = whoami.UserInfo.Username
envConfig.AccessToken = account.AccessToken
if envConfig.BridgeDataDir == "" {
envConfig.BridgeDataDir = filepath.Join(UserDataDir, "bbctl", ctx.String("env"))
}
return nil
}
69 changes: 63 additions & 6 deletions cmd/bbctl/login-email.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,85 @@ import (
"maunium.net/go/mautrix"

"github.com/beeper/bridge-manager/api/beeperapi"
"github.com/beeper/bridge-manager/cli/interactive"
)

var loginCommand = &cli.Command{
Name: "login",
Aliases: []string{"l"},
Usage: "Log into the Beeper server",
Before: interactive.Ask,
Action: beeperLogin,
Flags: []cli.Flag{
interactive.Flag{Flag: &cli.StringFlag{
&cli.StringFlag{
Name: "email",
EnvVars: []string{"BEEPER_EMAIL"},
Usage: "The Beeper account email to log in with",
}, Survey: &survey.Input{
Message: "Email:",
}},
},
&cli.BoolFlag{
Name: "no-desktop",
EnvVars: []string{"BBCTL_NO_DESKTOP_LOGIN"},
Usage: "Skip checking for an existing Beeper Desktop login",
},
},
}

func init() {
loginCommand.Flags = append(loginCommand.Flags, desktopLoginFlags()...)
}

func maybeUseDesktopLogin(ctx *cli.Context) (bool, error) {
if ctx.Bool("no-desktop") {
return false, nil
}
dbPath, err := getLoginDesktopAccountDBPath(ctx)
if err != nil {
return false, err
}
account, err := readDesktopAccount(ctx.Context, dbPath)
if err != nil {
if ctx.IsSet("desktop-data-dir") {
return false, err
}
return false, nil
}

useDesktop := false
err = survey.AskOne(&survey.Confirm{
Message: fmt.Sprintf("Use Beeper Desktop login for %s?", account.UserID),
Default: true,
}, &useDesktop)
if err != nil {
return false, err
}
if !useDesktop {
return false, nil
}

env, homeserver, err := configureDesktopLogin(ctx, account)
if err != nil {
return false, err
}
fmt.Printf("Using Beeper Desktop login for %s in bbctl env %q (%s)\n", account.UserID, env, homeserver)
return true, nil
}

func beeperLogin(ctx *cli.Context) error {
didLogin, err := maybeUseDesktopLogin(ctx)
if err != nil {
return err
} else if didLogin {
return nil
}

homeserver := ctx.String("homeserver")
email := ctx.String("email")
if email == "" {
err = survey.AskOne(&survey.Input{
Message: "Email:",
}, &email)
if err != nil {
return err
}
}

startLogin, err := beeperapi.StartLogin(homeserver)
if err != nil {
Expand Down Expand Up @@ -91,6 +147,7 @@ func doMatrixLogin(ctx *cli.Context, req *mautrix.ReqLogin, whoami *beeperapi.Re
envCfg.ClusterID = whoami.UserInfo.BridgeClusterID
envCfg.Username = whoami.UserInfo.Username
envCfg.AccessToken = resp.AccessToken
envCfg.DesktopDataDir = ""
envCfg.BridgeDataDir = filepath.Join(UserDataDir, "bbctl", ctx.String("env"))
err = cfg.Save()
if err != nil {
Expand Down
16 changes: 11 additions & 5 deletions cmd/bbctl/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,22 @@ var logoutCommand = &cli.Command{
}

func beeperLogout(ctx *cli.Context) error {
_, err := GetMatrixClient(ctx).Logout(ctx.Context)
if err != nil && !ctx.Bool("force") {
return fmt.Errorf("error logging out: %w", err)
envCfg := GetEnvConfig(ctx)
if !envCfg.UsesDesktopLogin() {
_, err := GetMatrixClient(ctx).Logout(ctx.Context)
if err != nil && !ctx.Bool("force") {
return fmt.Errorf("error logging out: %w", err)
}
}
cfg := GetConfig(ctx)
delete(cfg.Environments, ctx.String("env"))
err = cfg.Save()
if err != nil {
if err := cfg.Save(); err != nil {
return fmt.Errorf("error saving config: %w", err)
}
if envCfg.UsesDesktopLogin() {
fmt.Println("Logged out of bbctl successfully. Your Beeper Desktop session was not affected.")
return nil
}
fmt.Println("Logged out successfully")
return nil
}
15 changes: 15 additions & 0 deletions cmd/bbctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ func prepareApp(ctx *cli.Context) error {
envConfig := cfg.Environments.Get(env)
ctx.Context = context.WithValue(ctx.Context, contextKeyConfig, cfg)
ctx.Context = context.WithValue(ctx.Context, contextKeyEnvConfig, envConfig)
if envConfig.UsesDesktopLogin() && !isRecoveryCommand(ctx) {
err = loadDesktopLogin(ctx, envConfig)
if err != nil {
return fmt.Errorf("failed to use Beeper Desktop login: %w", err)
}
}
if envConfig.HasCredentials() {
if envConfig.Username == "" {
log.Printf("Fetching whoami to fill missing env config details")
Expand All @@ -95,6 +101,15 @@ func prepareApp(ctx *cli.Context) error {
return nil
}

func isRecoveryCommand(ctx *cli.Context) bool {
switch ctx.Args().First() {
case "login", "l", "login-password", "p", "logout":
return true
default:
return false
}
}

var app = &cli.App{
Name: "bbctl",
Usage: "Manage self-hosted bridges for Beeper",
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ require (
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
Expand Down
Loading
Loading