Skip to content

Commit 61a745d

Browse files
committed
Add bootstrap flow for hosted web loads
1 parent 250d3c2 commit 61a745d

23 files changed

Lines changed: 657 additions & 99 deletions

internal/api/handlers.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,35 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
190190
writeJSON(w, http.StatusOK, toDashboardResponse(data))
191191
}
192192

193+
func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
194+
ctx, span := apiTracer.Start(r.Context(), "api.bootstrap")
195+
defer span.End()
196+
r = r.WithContext(ctx)
197+
198+
var client *sdk.Client
199+
resolved, err := s.clientFunc(r)
200+
if err == nil {
201+
client = resolved
202+
} else if s.publicClient != nil {
203+
client = s.publicClient
204+
}
205+
206+
resp := BootstrapResponse{
207+
Hosted: s.hosted,
208+
}
209+
if client != nil {
210+
resp.Connected = true
211+
resp.RigHandle = client.RigHandle()
212+
resp.Mode = client.Mode()
213+
}
214+
if upstream := r.Header.Get("X-Wasteland"); upstream != "" {
215+
resp.ActiveUpstream = upstream
216+
}
217+
218+
w.Header().Set("Cache-Control", "no-store")
219+
writeJSON(w, http.StatusOK, resp)
220+
}
221+
193222
func (s *Server) handleLeaderboard(w http.ResponseWriter, r *http.Request) {
194223
client, ok := s.resolveClient(w, r)
195224
if !ok {

internal/api/infrastructure_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,45 @@ func TestNewHostedWorkspace_ConfigIncludesWorkspaceAndActiveUpstream(t *testing.
117117
}
118118
}
119119

120+
func TestBootstrap_ReturnsRigHandleAndNoStore(t *testing.T) {
121+
srv := New(newTestClient(newFakeDB()))
122+
ts := httptest.NewServer(srv)
123+
defer ts.Close()
124+
125+
req, err := http.NewRequest(http.MethodGet, ts.URL+"/api/bootstrap", nil)
126+
if err != nil {
127+
t.Fatalf("new request: %v", err)
128+
}
129+
req.Header.Set("X-Wasteland", "stale/upstream")
130+
131+
resp, err := http.DefaultClient.Do(req)
132+
if err != nil {
133+
t.Fatalf("GET /api/bootstrap: %v", err)
134+
}
135+
defer resp.Body.Close() //nolint:errcheck // test cleanup
136+
137+
if resp.StatusCode != http.StatusOK {
138+
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
139+
}
140+
if got := resp.Header.Get("Cache-Control"); got != "no-store" {
141+
t.Fatalf("Cache-Control = %q, want %q", got, "no-store")
142+
}
143+
144+
var boot BootstrapResponse
145+
if err := json.NewDecoder(resp.Body).Decode(&boot); err != nil {
146+
t.Fatalf("decode bootstrap: %v", err)
147+
}
148+
if boot.RigHandle != "alice" {
149+
t.Fatalf("rig_handle = %q, want %q", boot.RigHandle, "alice")
150+
}
151+
if !boot.Connected {
152+
t.Fatalf("expected connected bootstrap response")
153+
}
154+
if boot.ActiveUpstream != "stale/upstream" {
155+
t.Fatalf("active_upstream = %q, want %q", boot.ActiveUpstream, "stale/upstream")
156+
}
157+
}
158+
120159
func TestSetProfileQuerier_OverridesProfileSource(t *testing.T) {
121160
sheetJSON := `{"identity":{"display_name":"Injected"},"value_dimensions":{"quality":0.7}}`
122161
pq := &fakePileQuerier{rows: map[string][]map[string]any{

internal/api/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package api
33
// registerRoutes wires all API endpoints onto the server mux.
44
func (s *Server) registerRoutes() {
55
// Read endpoints.
6+
s.mux.HandleFunc("GET /api/bootstrap", s.handleBootstrap)
67
s.mux.HandleFunc("GET /api/wanted", s.handleBrowse)
78
s.mux.HandleFunc("GET /api/wanted/{id}", s.handleDetail)
89
s.mux.HandleFunc("GET /api/dashboard", s.handleDashboard)

internal/api/types.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,15 @@ type UpstreamInfoJSON struct {
129129
Mode string `json:"mode"`
130130
}
131131

132+
// WastelandConfigJSON is the JSON representation of a hosted joined wasteland.
133+
type WastelandConfigJSON struct {
134+
Upstream string `json:"upstream"`
135+
ForkOrg string `json:"fork_org"`
136+
ForkDB string `json:"fork_db"`
137+
Mode string `json:"mode"`
138+
Signing bool `json:"signing"`
139+
}
140+
132141
// ConfigResponse is the JSON response for GET /api/config.
133142
type ConfigResponse struct {
134143
RigHandle string `json:"rig_handle"`
@@ -139,6 +148,18 @@ type ConfigResponse struct {
139148
Upstreams []UpstreamInfoJSON `json:"upstreams,omitempty"`
140149
}
141150

151+
// BootstrapResponse is the JSON response for GET /api/bootstrap.
152+
type BootstrapResponse struct {
153+
Authenticated bool `json:"authenticated"`
154+
Connected bool `json:"connected"`
155+
Hosted bool `json:"hosted,omitempty"`
156+
RigHandle string `json:"rig_handle,omitempty"`
157+
Wastelands []WastelandConfigJSON `json:"wastelands,omitempty"`
158+
Environment string `json:"environment,omitempty"`
159+
ActiveUpstream string `json:"active_upstream,omitempty"`
160+
Mode string `json:"mode,omitempty"`
161+
}
162+
142163
// LeaderboardEntryJSON is the JSON representation of a leaderboard entry.
143164
type LeaderboardEntryJSON struct {
144165
RigHandle string `json:"rig_handle"`

internal/hosted/auth.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,14 @@ func (s *Server) AuthMiddleware(next http.Handler) http.Handler {
113113
upstream := r.Header.Get("X-Wasteland")
114114
upstreams := workspace.Upstreams()
115115

116-
if upstream == "" && len(upstreams) > 0 && r.Method == http.MethodGet {
117-
// Default to first upstream for reads when header is missing
118-
// (race with frontend context init, impersonation, or
119-
// single-wasteland backward compat).
120-
upstream = upstreams[0].Upstream
116+
if upstream == "" && r.Method == http.MethodGet {
117+
if remembered := s.sessions.ActiveUpstream(sessionID); remembered != "" {
118+
upstream = remembered
119+
} else if len(upstreams) > 0 {
120+
// Default to the first upstream for backward compatibility when
121+
// bootstrap has not established an explicit choice yet.
122+
upstream = upstreams[0].Upstream
123+
}
121124
}
122125

123126
if upstream == "" {
@@ -136,6 +139,7 @@ func (s *Server) AuthMiddleware(next http.Handler) http.Handler {
136139
passOrBlock(w, r, http.StatusBadRequest, "unknown upstream: "+upstream)
137140
return
138141
}
142+
s.sessions.RememberActiveUpstream(sessionID, upstream)
139143

140144
// Staging-only impersonation: X-Impersonate header overrides rig handle
141145
// for read-only requests so operators can see the UI as another user.

internal/hosted/auth_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"net/http/httptest"
88
"strings"
99
"testing"
10+
11+
"github.com/gastownhall/wasteland/internal/api"
1012
)
1113

1214
const testSecret = "test-session-secret"
@@ -83,6 +85,7 @@ func setupHostedTestServer(t *testing.T) (*SessionStore, *httptest.Server) {
8385
mux := http.NewServeMux()
8486
mux.HandleFunc("POST /api/auth/connect", server.handleConnect)
8587
mux.HandleFunc("GET /api/auth/status", server.handleAuthStatus)
88+
mux.HandleFunc("GET /api/bootstrap", server.handleBootstrap)
8689
mux.HandleFunc("POST /api/auth/logout", server.handleLogout)
8790
mux.HandleFunc("POST /api/auth/connect-session", server.handleConnectSession)
8891
mux.HandleFunc("POST /api/auth/join", server.handleJoin)
@@ -148,6 +151,7 @@ func setupMultiWastelandTestServer(t *testing.T) (*SessionStore, *httptest.Serve
148151
})
149152

150153
mux := http.NewServeMux()
154+
mux.HandleFunc("GET /api/bootstrap", server.handleBootstrap)
151155
mux.Handle("/", server.AuthMiddleware(inner))
152156

153157
ts := httptest.NewServer(mux)
@@ -486,6 +490,72 @@ func TestHandleAuthStatus_Authenticated(t *testing.T) {
486490
}
487491
}
488492

493+
func TestHandleBootstrap_Authenticated_RemembersAndReturnsActiveUpstream(t *testing.T) {
494+
sessions, ts := setupMultiWastelandTestServer(t)
495+
496+
sessionID, _ := sessions.Create("conn-1")
497+
req, _ := http.NewRequest("GET", ts.URL+"/api/bootstrap", nil)
498+
req.Header.Set("X-Wasteland", "gastownhall/gascity")
499+
req.AddCookie(&http.Cookie{
500+
Name: cookieName,
501+
Value: SignSessionCookie(sessionID, "conn-1", testSecret),
502+
})
503+
504+
resp, err := http.DefaultClient.Do(req)
505+
if err != nil {
506+
t.Fatal(err)
507+
}
508+
defer resp.Body.Close() //nolint:errcheck // test cleanup
509+
510+
if got := resp.Header.Get("Cache-Control"); got != "no-store" {
511+
t.Fatalf("Cache-Control = %q, want %q", got, "no-store")
512+
}
513+
514+
var boot api.BootstrapResponse
515+
if err := json.NewDecoder(resp.Body).Decode(&boot); err != nil {
516+
t.Fatalf("decode bootstrap: %v", err)
517+
}
518+
if !boot.Authenticated || !boot.Connected {
519+
t.Fatalf("expected authenticated connected bootstrap, got %+v", boot)
520+
}
521+
if boot.ActiveUpstream != "gastownhall/gascity" {
522+
t.Fatalf("active_upstream = %q, want %q", boot.ActiveUpstream, "gastownhall/gascity")
523+
}
524+
if boot.Mode != "pr" {
525+
t.Fatalf("mode = %q, want %q", boot.Mode, "pr")
526+
}
527+
if got := sessions.ActiveUpstream(sessionID); got != "gastownhall/gascity" {
528+
t.Fatalf("remembered upstream = %q, want %q", got, "gastownhall/gascity")
529+
}
530+
}
531+
532+
func TestHandleBootstrap_UsesRememberedUpstreamWhenHeaderMissing(t *testing.T) {
533+
sessions, ts := setupMultiWastelandTestServer(t)
534+
535+
sessionID, _ := sessions.Create("conn-1")
536+
sessions.RememberActiveUpstream(sessionID, "gastownhall/gascity")
537+
538+
req, _ := http.NewRequest("GET", ts.URL+"/api/bootstrap", nil)
539+
req.AddCookie(&http.Cookie{
540+
Name: cookieName,
541+
Value: SignSessionCookie(sessionID, "conn-1", testSecret),
542+
})
543+
544+
resp, err := http.DefaultClient.Do(req)
545+
if err != nil {
546+
t.Fatal(err)
547+
}
548+
defer resp.Body.Close() //nolint:errcheck // test cleanup
549+
550+
var boot api.BootstrapResponse
551+
if err := json.NewDecoder(resp.Body).Decode(&boot); err != nil {
552+
t.Fatalf("decode bootstrap: %v", err)
553+
}
554+
if boot.ActiveUpstream != "gastownhall/gascity" {
555+
t.Fatalf("active_upstream = %q, want %q", boot.ActiveUpstream, "gastownhall/gascity")
556+
}
557+
}
558+
489559
func TestHandleLogout(t *testing.T) {
490560
sessions, ts := setupHostedTestServer(t)
491561

internal/hosted/resolver.go

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,28 @@ func (wr *WorkspaceResolver) ResolveContext(ctx context.Context, session *UserSe
144144
return wr.waitOnResolveResult(ctx, span, resultCh)
145145
}
146146

147+
// WarmSession primes the workspace cache using metadata already fetched on the
148+
// boot path. It shares the same singleflight key as live resolves.
149+
func (wr *WorkspaceResolver) WarmSession(session *UserSession, apiKey string, meta *UserMetadata) {
150+
if session == nil || session.ConnectionID == "" || meta == nil || len(meta.Wastelands) == 0 {
151+
return
152+
}
153+
go func() {
154+
ctx, cancel := ctxutil.Detached(context.Background(), resolveMissTimeout)
155+
defer cancel()
156+
if _, ok := wr.cachedWorkspace(session.ConnectionID); ok {
157+
return
158+
}
159+
resultCh := wr.group.DoChan(session.ConnectionID, func() (any, error) {
160+
return wr.resolveFromMetadata(ctx, session.ConnectionID, apiKey, meta)
161+
})
162+
select {
163+
case <-resultCh:
164+
case <-ctx.Done():
165+
}
166+
}()
167+
}
168+
147169
func (wr *WorkspaceResolver) waitOnResolveResult(ctx context.Context, span trace.Span, resultCh <-chan singleflight.Result) (*sdk.Workspace, error) {
148170
_, waitSpan := hostedTracer.Start(ctx, "hosted.workspace.wait_for_resolve")
149171
defer waitSpan.End()
@@ -182,16 +204,25 @@ func (wr *WorkspaceResolver) resolveMiss(ctx context.Context, session *UserSessi
182204
resolveSpan.RecordError(err)
183205
return nil, fmt.Errorf("resolving credentials: %w", err)
184206
}
207+
workspace, err := wr.resolveFromMetadata(resolveCtx, session.ConnectionID, apiKey, meta)
208+
if err != nil {
209+
resolveSpan.RecordError(err)
210+
return nil, err
211+
}
212+
resolveSpan.SetAttributes(attribute.Int("wasteland.count", len(meta.Wastelands)))
213+
return workspace, nil
214+
}
215+
216+
func (wr *WorkspaceResolver) resolveFromMetadata(_ context.Context, connectionID, apiKey string, meta *UserMetadata) (*sdk.Workspace, error) {
185217
if meta == nil || len(meta.Wastelands) == 0 {
186-
return nil, fmt.Errorf("no wasteland config found for connection %s", session.ConnectionID)
218+
return nil, fmt.Errorf("no wasteland config found for connection %s", connectionID)
187219
}
188220

189221
ws := sdk.NewWorkspace(meta.RigHandle)
190222
for i := range meta.Wastelands {
191223
wl := &meta.Wastelands[i]
192-
client, err := wr.buildClient(wl, meta.RigHandle, session.ConnectionID, apiKey, meta)
224+
client, err := wr.buildClient(wl, meta.RigHandle, connectionID, apiKey, meta)
193225
if err != nil {
194-
resolveSpan.RecordError(err)
195226
return nil, fmt.Errorf("building client for %s: %w", wl.Upstream, err)
196227
}
197228
ws.Add(sdk.UpstreamInfo{
@@ -202,8 +233,7 @@ func (wr *WorkspaceResolver) resolveMiss(ctx context.Context, session *UserSessi
202233
}, client)
203234
}
204235

205-
resolveSpan.SetAttributes(attribute.Int("wasteland.count", len(meta.Wastelands)))
206-
wr.cacheWorkspace(session.ConnectionID, ws)
236+
wr.cacheWorkspace(connectionID, ws)
207237
return ws, nil
208238
}
209239

0 commit comments

Comments
 (0)