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
68 changes: 61 additions & 7 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,58 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)

const (
apiBase = "https://v2-12-2.api.getgymbros.com"
defaultAPIBase = "https://v2-12-3.api.getgymbros.com"
refreshProcedure = "user.refreshToken"
userAgent = "Liftoff/528 CFNetwork/3860.400.51 Darwin/25.3.0"
)

const apiBaseEnvVar = "LIFTOFF_API_BASE"

var (
resolveOnce sync.Once
resolved string
)

// ResolveAPIBase returns the env override LIFTOFF_API_BASE if set, else the
// compiled-in default. Liftoff mints version-pinned hosts (e.g. v2-13-0) and
// retires older ones; the env var lets users dodge a deprecation without
// waiting for a new release. Logged once when an override is in effect so
// users can tell which endpoint is active when something breaks.
func ResolveAPIBase() string {
resolveOnce.Do(func() {
if v := strings.TrimSpace(os.Getenv(apiBaseEnvVar)); v != "" {
resolved = strings.TrimRight(v, "/")
fmt.Fprintf(os.Stderr, "liftoff-export: using %s=%s\n", apiBaseEnvVar, resolved)
} else {
resolved = defaultAPIBase
}
})
return resolved
}

// deprecatedMarker is the substring the Liftoff backend returns when the
// version-pinned host this binary targets has been retired.
const deprecatedMarker = "server is deprecated"

func DeprecatedError(action string) error {
return fmt.Errorf("%s: Liftoff retired the API version this binary targets (%s). "+
"Workarounds: (a) update to a newer liftoff-export release, or "+
"(b) set %s=https://vX-Y-Z.api.getgymbros.com to point at a current version. "+
"The Liftoff iOS/Android app shows its version under Settings → About; matching that is usually safe.",
action, ResolveAPIBase(), apiBaseEnvVar)
}

type TokenStore struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
Expand Down Expand Up @@ -51,7 +89,7 @@ func Refresh(refreshToken string) (*TokenStore, error) {
"0": map[string]any{"json": refreshToken},
})
reqURL := fmt.Sprintf("%s/api/trpc/%s?batch=1&input=%s",
apiBase, refreshProcedure, url.QueryEscape(string(input)))
ResolveAPIBase(), refreshProcedure, url.QueryEscape(string(input)))

req, _ := http.NewRequest("GET", reqURL, nil)
req.Header.Set("Accept", "*/*")
Expand All @@ -63,6 +101,14 @@ func Refresh(refreshToken string) (*TokenStore, error) {
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if strings.Contains(strings.ToLower(string(body)), deprecatedMarker) {
return nil, DeprecatedError("token refresh")
}

var batch []struct {
Result *struct {
Data struct {
Expand All @@ -76,8 +122,8 @@ func Refresh(refreshToken string) (*TokenStore, error) {
JSON struct{ Message string `json:"message"` } `json:"json"`
} `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&batch); err != nil {
return nil, err
if err := json.Unmarshal(body, &batch); err != nil {
return nil, fmt.Errorf("parse tRPC response: %w\nbody: %s", err, string(body))
}
if len(batch) == 0 || batch[0].Error != nil {
msg := "unknown error"
Expand Down Expand Up @@ -117,7 +163,7 @@ func Login(email, password string) error {
},
},
})
reqURL := fmt.Sprintf("%s/api/trpc/user.signIn?batch=1", apiBase)
reqURL := fmt.Sprintf("%s/api/trpc/user.signIn?batch=1", ResolveAPIBase())
req, _ := http.NewRequest("POST", reqURL, bytes.NewReader(input))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "*/*")
Expand All @@ -129,6 +175,14 @@ func Login(email, password string) error {
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if strings.Contains(strings.ToLower(string(body)), deprecatedMarker) {
return DeprecatedError("login")
}

var batch []struct {
Result *struct {
Data struct {
Expand All @@ -143,8 +197,8 @@ func Login(email, password string) error {
JSON struct{ Message string `json:"message"` } `json:"json"`
} `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&batch); err != nil {
return err
if err := json.Unmarshal(body, &batch); err != nil {
return fmt.Errorf("parse tRPC response: %w\nbody: %s", err, string(body))
}
if len(batch) == 0 || batch[0].Result == nil {
msg := "unknown error"
Expand Down
10 changes: 6 additions & 4 deletions internal/client/trpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,11 @@ import (
"io"
"net/http"
"net/url"
"strings"

"github.com/quantcli/liftoff-export-cli/internal/auth"
)

// Base URL — versioned per Liftoff release. Update via: liftoff config set-url <url>
const BaseURL = "https://v2-12-2.api.getgymbros.com"

// UserAgent matches the iOS app so the server accepts our requests.
const UserAgent = "Liftoff/528 CFNetwork/3860.400.51 Darwin/25.3.0"

Expand All @@ -24,7 +22,7 @@ type Client struct {
func New() *Client {
return &Client{
http: &http.Client{},
baseURL: BaseURL,
baseURL: auth.ResolveAPIBase(),
}
}

Expand Down Expand Up @@ -76,6 +74,10 @@ func (c *Client) Query(procedure string, input any, out any) error {
return err
}

if strings.Contains(strings.ToLower(string(body)), "server is deprecated") {
return auth.DeprecatedError(fmt.Sprintf("tRPC %s", procedure))
}

if resp.StatusCode != 200 {
return fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
}
Expand Down
Loading