Skip to content

Commit 038ba88

Browse files
julianknutsenclaude
andcommitted
fix: validate DoltHub token at connect time and show expiry reason on reconnect
Three improvements for users with invalid/expired DoltHub API keys: 1. Proactive validation: handleConnect and handleJoin now probe DoltHub with the API key before creating a session. Bad keys are rejected immediately with a clear message about tokens vs credentials. 2. Auth-aware error handling: handleBrowse and handleDetail now detect "invalid authorization" errors and return 401 instead of serving stale data or a generic "upstream unavailable" 503. This triggers the frontend re-auth redirect. 3. Expiry UX: the frontend now passes reason=expired when redirecting to /connect on 401, and the connect page shows a styled error banner explaining the token expired and linking to DoltHub token settings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e58db68 commit 038ba88

6 files changed

Lines changed: 110 additions & 4 deletions

File tree

internal/api/handlers.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) {
5252
return json.Marshal(toBrowseResponse(result))
5353
})
5454
if err != nil {
55+
// Auth errors should not serve stale data — the user needs to reconnect.
56+
if isUpstreamAuthError(err) {
57+
writeError(w, http.StatusUnauthorized, "DoltHub credentials expired — please reconnect.")
58+
return
59+
}
5560
// Try to serve stale cache data with a warning instead of a hard error.
5661
stale := s.browseCache.GetStale(key)
5762
if stale != nil {
@@ -104,6 +109,11 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
104109
writeError(w, http.StatusNotFound, err.Error())
105110
return
106111
}
112+
// Auth errors should not serve stale data — the user needs to reconnect.
113+
if isUpstreamAuthError(err) {
114+
writeError(w, http.StatusUnauthorized, "DoltHub credentials expired — please reconnect.")
115+
return
116+
}
107117
// Try stale cache before returning a hard error.
108118
if stale := s.detailCache.GetStale(key); stale != nil {
109119
slog.Warn("serving stale detail data due to upstream error", "error", err)
@@ -227,12 +237,17 @@ func (s *Server) invalidateAllCaches() {
227237
s.detailCache.Invalidate()
228238
}
229239

240+
// isUpstreamAuthError returns true if the error is a DoltHub authentication
241+
// failure (expired or invalid API key).
242+
func isUpstreamAuthError(err error) bool {
243+
return strings.Contains(err.Error(), "invalid authorization")
244+
}
245+
230246
// writeUpstreamError classifies DoltHub errors and writes an appropriate response:
231247
// - "invalid authorization" → 401 (triggers frontend re-auth)
232248
// - other upstream errors → 503 with sanitized message + Sentry capture
233249
func writeUpstreamError(w http.ResponseWriter, err error, label string) {
234-
msg := err.Error()
235-
if strings.Contains(msg, "invalid authorization") {
250+
if isUpstreamAuthError(err) {
236251
writeError(w, http.StatusUnauthorized, "DoltHub credentials expired — please reconnect.")
237252
return
238253
}

internal/hosted/main_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package hosted
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
func TestMain(m *testing.M) {
9+
// Disable the real DoltHub probe during tests — test Nango servers
10+
// return fake API keys that would fail validation.
11+
ProbeDoltHubToken = func(string) error { return nil }
12+
os.Exit(m.Run())
13+
}

internal/hosted/server.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package hosted
22

33
import (
44
"encoding/json"
5+
"fmt"
6+
"io"
57
"io/fs"
68
"log/slog"
79
"net/http"
10+
"strings"
811
"time"
912

1013
"github.com/gastownhall/wasteland/internal/api"
@@ -106,6 +109,15 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
106109
if err != nil || meta == nil {
107110
meta = &UserMetadata{RigHandle: req.RigHandle}
108111
}
112+
113+
// Verify the DoltHub API key works before proceeding.
114+
if err := ProbeDoltHubToken(apiKey); err != nil {
115+
writeJSON(w, http.StatusBadRequest, map[string]string{
116+
"error": "DoltHub API key is invalid — please reconnect your DoltHub account. Verify you created an API token (not a credential) in DoltHub.",
117+
})
118+
return
119+
}
120+
109121
meta.RigHandle = req.RigHandle
110122
meta.UpsertWasteland(WastelandConfig{
111123
Upstream: req.Upstream,
@@ -309,6 +321,14 @@ func (s *Server) handleJoin(w http.ResponseWriter, r *http.Request) {
309321
return
310322
}
311323

324+
// Verify the DoltHub API key still works before joining.
325+
if err := ProbeDoltHubToken(apiKey); err != nil {
326+
writeJSON(w, http.StatusBadRequest, map[string]string{
327+
"error": "DoltHub API key is invalid — please reconnect your DoltHub account. Verify you created an API token (not a credential) in DoltHub.",
328+
})
329+
return
330+
}
331+
312332
meta.UpsertWasteland(WastelandConfig{
313333
Upstream: req.Upstream,
314334
ForkOrg: req.ForkOrg,
@@ -465,6 +485,36 @@ func healthHandler() http.HandlerFunc {
465485
}
466486
}
467487

488+
// ProbeDoltHubToken verifies a DoltHub API key by running a lightweight query.
489+
// Returns nil if the key is valid (or empty), error if DoltHub rejects it.
490+
// Var so tests can override.
491+
var ProbeDoltHubToken = func(apiKey string) error {
492+
if apiKey == "" {
493+
return nil
494+
}
495+
req, err := http.NewRequest("GET",
496+
"https://www.dolthub.com/api/v1alpha1/hop/wl-commons/main?q=SELECT%201", nil)
497+
if err != nil {
498+
return nil // don't block connect for request construction errors
499+
}
500+
req.Header.Set("authorization", "token "+apiKey)
501+
502+
client := &http.Client{Timeout: 5 * time.Second}
503+
resp, err := client.Do(req)
504+
if err != nil {
505+
return nil // network error — don't block connect for transient issues
506+
}
507+
defer resp.Body.Close() //nolint:errcheck // best-effort close
508+
509+
if resp.StatusCode == http.StatusBadRequest {
510+
body, _ := io.ReadAll(resp.Body)
511+
if strings.Contains(string(body), "invalid authorization") {
512+
return fmt.Errorf("DoltHub rejected the API key")
513+
}
514+
}
515+
return nil
516+
}
517+
468518
// writeJSON writes a JSON response (duplicated here to avoid circular import
469519
// with the api package, which provides the canonical version).
470520
func writeJSON(w http.ResponseWriter, status int, v any) {

web/src/api/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
111111
if (resp.status === 401 || resp.status === 412) {
112112
if (typeof window !== "undefined" && !window.location.pathname.startsWith("/connect")) {
113113
const returnTo = window.location.pathname + window.location.search;
114-
window.location.href = `/connect?return_to=${encodeURIComponent(returnTo)}`;
114+
const reason = resp.status === 401 ? "&reason=expired" : "";
115+
window.location.href = `/connect?return_to=${encodeURIComponent(returnTo)}${reason}`;
115116
// Return a never-resolving promise to prevent callers from processing stale data.
116117
return new Promise<T>(() => {});
117118
}

web/src/components/ConnectPage.module.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@
6060
line-height: 1.5;
6161
}
6262

63+
.errorHint {
64+
color: var(--red);
65+
font-size: var(--text-sm);
66+
margin-bottom: var(--space-3);
67+
line-height: 1.5;
68+
padding: 10px 14px;
69+
background: color-mix(in srgb, var(--red) 8%, transparent);
70+
border: 1px solid color-mix(in srgb, var(--red) 25%, transparent);
71+
border-radius: var(--radius-sm);
72+
}
73+
6374
.actions {
6475
display: flex;
6576
gap: var(--space-2);

web/src/components/ConnectPage.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export function ConnectPage() {
1414

1515
const rawReturnTo = searchParams.get("return_to");
1616
const returnTo = rawReturnTo && /^\/[^/]/.test(rawReturnTo) ? rawReturnTo : null;
17+
const reason = searchParams.get("reason");
1718
const [view, setView] = useState<"connect" | "join">("connect");
1819
const [loading, setLoading] = useState(true);
1920
const [submitting, setSubmitting] = useState(false);
@@ -129,7 +130,22 @@ export function ConnectPage() {
129130

130131
return (
131132
<div className={styles.page}>
132-
{returnTo && <p className={styles.hint}>Sign in to continue.</p>}
133+
{reason === "expired" && (
134+
<p className={styles.errorHint}>
135+
Your DoltHub API token has expired or is invalid. Please reconnect. Make sure you create an API{" "}
136+
<strong>token</strong> (not a credential) at{" "}
137+
<a
138+
href="https://www.dolthub.com/settings/tokens"
139+
target="_blank"
140+
rel="noopener noreferrer"
141+
className={styles.link}
142+
>
143+
dolthub.com/settings/tokens
144+
</a>
145+
.
146+
</p>
147+
)}
148+
{returnTo && !reason && <p className={styles.hint}>Sign in to continue.</p>}
133149
<h2 className={styles.heading}>{view === "join" ? "Join a Wasteland" : "Connect to Wasteland"}</h2>
134150

135151
{view === "join" && (

0 commit comments

Comments
 (0)