From 90336000bd5cbfe78735204b89b409eff440b617 Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Sat, 25 Apr 2026 11:07:25 -0400
Subject: [PATCH] fix(auth): bump API host to v2-12-3 and add LIFTOFF_API_BASE
override
Liftoff retired v2-12-2 with "The server is deprecated", breaking every
authenticated subcommand once the cached access token expires. v2-12-3
is currently the only live host (v2-13-0..2 also return deprecated;
v2-14+ don't resolve). Bumping the default fixes today's breakage; the
new LIFTOFF_API_BASE env var lets users dodge the next retirement
without waiting for a release.
When the backend returns the deprecation marker (from refresh, login,
or any tRPC query), surface an actionable error pointing users at the
env-var workaround instead of the bare upstream message.
---
internal/auth/auth.go | 68 ++++++++++++++++++++++++++++++++++++-----
internal/client/trpc.go | 10 +++---
2 files changed, 67 insertions(+), 11 deletions(-)
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
index 08a1c41..2845d67 100644
--- a/internal/auth/auth.go
+++ b/internal/auth/auth.go
@@ -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"`
@@ -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", "*/*")
@@ -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 {
@@ -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"
@@ -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", "*/*")
@@ -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 {
@@ -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"
diff --git a/internal/client/trpc.go b/internal/client/trpc.go
index b03cea1..ac15946 100644
--- a/internal/client/trpc.go
+++ b/internal/client/trpc.go
@@ -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
-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"
@@ -24,7 +22,7 @@ type Client struct {
func New() *Client {
return &Client{
http: &http.Client{},
- baseURL: BaseURL,
+ baseURL: auth.ResolveAPIBase(),
}
}
@@ -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))
}