Skip to content

Commit de15813

Browse files
fix(claim): send canonical token wire field + surface session_token (#15)
Two user-impacting bugs in Client.Claim: 1. Wire-name drift. The SDK was posting body["jwt"] = opts.JWT — the deprecated alias. The api ClaimRequest doc explicitly names sdk-go as one of three drift sources for the legacy `jwt` field (with dashboard + MCP). Server accepts both with `token` winning on collision, so this is wire-compatible. 2. ClaimResult.SessionToken missing. The api /claim handler returns a freshly minted 24h session JWT in the response body, but the SDK's result struct contained no field for it — every caller that wanted to provision resources right after Claim() had to run a separate magic-link login round-trip. Changes: - ClaimResult gains SessionToken `json:"session_token,omitempty"`. - ClaimOpts gains canonical Token field (json:"token"); existing JWT field retained as deprecated alias (json:"-") with claimToken() helper that mirrors api.ClaimRequest.claimToken precedence (Token wins). - claim.go sends body["token"] = opts.claimToken(); validation message updated from "JWT is required" → "Token is required". - Tests: TestClaim asserts wire body contains no `jwt` and has `token`, plus SessionToken round-trip. Two regression tests added — TestClaim_JWTFieldBackwardCompat (deprecated field still compiles and the SDK still translates to canonical wire) and TestClaim_TokenWinsOverJWT (precedence rule mirrors api). Existing callers compile unchanged (JWT field retained). Project coverage 97.8% (>95% floor), patch coverage 100%.
1 parent 78ff64b commit de15813

6 files changed

Lines changed: 156 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,28 @@ All notable changes to the Go SDK for instanode.dev are documented here.
55
The SDK follows semver: minor bumps add new API surface, major bumps break
66
existing callers.
77

8+
## Unreleased
9+
10+
### Fixed
11+
12+
- **`Client.Claim` now sends the canonical `token` wire field instead of the
13+
deprecated `jwt` alias.** The api ClaimRequest doc names the Go SDK as one
14+
of three drift sources for the legacy `jwt` name (alongside the dashboard
15+
and MCP). The server still accepts both, so this is wire-compatible, but
16+
closes the drift.
17+
18+
### Added
19+
20+
- **`ClaimResult.SessionToken`** (`string`, `json:"session_token,omitempty"`).
21+
Populated when the api mints a session JWT for the newly created team on
22+
`POST /claim`. Callers can use it as the Bearer token for follow-up
23+
authenticated requests with no separate login round-trip. 24h TTL.
24+
- **`ClaimOpts.Token`** (`string`, `json:"token,omitempty"`) — canonical
25+
onboarding-token field, mirrors api `ClaimRequest.Token`. The existing
26+
`ClaimOpts.JWT` field is retained as a deprecated fallback (no JSON tag —
27+
read-only by the SDK on the client side); new callers should use `Token`.
28+
When both are set, `Token` wins.
29+
830
## v0.3.0 — 2026-05-20 (BugBash B17)
931

1032
### Fixed

instant/claim.go

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,29 @@ import (
77

88
// Claim converts an anonymous session into a registered team account.
99
//
10-
// The JWT is the onboarding token obtained from the upgrade URL. When
10+
// The Token is the onboarding token obtained from the upgrade URL. When
1111
// instanode.dev provisions an anonymous resource it returns a Note field
12-
// containing a URL like https://instanode.dev/start?t=<jwt>. Extract the
12+
// containing a URL like https://instanode.dev/start?t=<token>. Extract the
1313
// "t" query parameter and pass it here.
1414
//
15-
// Claim is one-time: anonymous (24h TTL) resources associated with the JWT's
16-
// fingerprint are transferred to the new team and given a permanent (no-expiry)
17-
// lifetime on the free tier. No trial period is started — paid tiers (hobby,
18-
// pro, team) require a separate Razorpay checkout from the dashboard.
15+
// Claim is one-time: anonymous (24h TTL) resources associated with the
16+
// token's fingerprint are transferred to the new team and given a permanent
17+
// (no-expiry) lifetime on the free tier. No trial period is started — paid
18+
// tiers (hobby, pro, team) require a separate Razorpay checkout from the
19+
// dashboard.
1920
//
20-
// Returns [*APIError] with StatusCode 409 if the JWT has already been claimed.
21+
// On success the returned [ClaimResult.SessionToken] holds a freshly minted
22+
// 24h session JWT for the new team — callers can pass it as the Bearer
23+
// token on follow-up authenticated requests without a separate login round
24+
// trip.
25+
//
26+
// Returns [*APIError] with StatusCode 409 if the token has already been
27+
// claimed.
2128
//
2229
// Example:
2330
//
2431
// result, err := client.Claim(ctx, instant.ClaimOpts{
25-
// JWT: upgradeToken, // from "t" query param of the upgrade URL
32+
// Token: upgradeToken, // from "t" query param of the upgrade URL
2633
// Email: "dev@example.com",
2734
// TeamName: "Acme Corp", // optional; defaults to email
2835
// })
@@ -31,17 +38,22 @@ import (
3138
// return
3239
// }
3340
// if err != nil { log.Fatal(err) }
34-
// fmt.Println("team_id:", result.TeamID)
41+
// fmt.Println("team_id:", result.TeamID, "session:", result.SessionToken)
3542
func (c *Client) Claim(ctx context.Context, opts ClaimOpts) (*ClaimResult, error) {
36-
if opts.JWT == "" {
37-
return nil, fmt.Errorf("Claim: JWT is required")
43+
token := opts.claimToken()
44+
if token == "" {
45+
return nil, fmt.Errorf("Claim: Token is required")
3846
}
3947
if opts.Email == "" {
4048
return nil, fmt.Errorf("Claim: Email is required")
4149
}
4250

51+
// Canonical wire field is `token` (api ClaimRequest, 2026-05-20). The
52+
// legacy `jwt` alias is still server-accepted but documented as
53+
// deprecated — SDKs are one of the named drift sources, so we send the
54+
// canonical name only.
4355
body := map[string]string{
44-
"jwt": opts.JWT,
56+
"token": token,
4557
"email": opts.Email,
4658
}
4759
if opts.TeamName != "" {

instant/client_test.go

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -542,30 +542,92 @@ func TestAbsoluteURL(t *testing.T) {
542542
func TestClaim(t *testing.T) {
543543
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
544544
body := decodeJSONBody(t, r.Body)
545-
if body["jwt"] != "ey.j.j" || body["email"] != "a@b.c" || body["team_name"] != "Acme" {
545+
// Canonical wire field is `token` (api ClaimRequest, 2026-05-20).
546+
// The SDK must never send the deprecated `jwt` alias even when the
547+
// caller supplied the deprecated [ClaimOpts.JWT] field.
548+
if _, hasJWT := body["jwt"]; hasJWT {
549+
t.Errorf("body must not contain deprecated `jwt` field; got %+v", body)
550+
}
551+
if body["token"] != "ey.j.j" || body["email"] != "a@b.c" || body["team_name"] != "Acme" {
546552
t.Errorf("body = %+v", body)
547553
}
548554
_ = json.NewEncoder(w).Encode(map[string]any{
549-
"ok": true, "team_id": "T", "user_id": "U", "message": "ok",
555+
"ok": true,
556+
"team_id": "T",
557+
"user_id": "U",
558+
"session_token": "sess.jwt.tok",
559+
"message": "ok",
550560
})
551561
}))
552562
defer srv.Close()
553563
c := New(WithBaseURL(srv.URL))
554-
r, err := c.Claim(context.Background(), ClaimOpts{JWT: "ey.j.j", Email: "a@b.c", TeamName: "Acme"})
564+
r, err := c.Claim(context.Background(), ClaimOpts{Token: "ey.j.j", Email: "a@b.c", TeamName: "Acme"})
555565
if err != nil {
556566
t.Fatalf("Claim: %v", err)
557567
}
558568
if r.TeamID != "T" {
559569
t.Errorf("TeamID = %q", r.TeamID)
560570
}
571+
if r.SessionToken != "sess.jwt.tok" {
572+
t.Errorf("SessionToken = %q, want sess.jwt.tok", r.SessionToken)
573+
}
561574
if _, err := c.Claim(context.Background(), ClaimOpts{Email: "x"}); err == nil {
562-
t.Error("missing JWT should error")
575+
t.Error("missing Token should error")
563576
}
564-
if _, err := c.Claim(context.Background(), ClaimOpts{JWT: "x"}); err == nil {
577+
if _, err := c.Claim(context.Background(), ClaimOpts{Token: "x"}); err == nil {
565578
t.Error("missing Email should error")
566579
}
567580
}
568581

582+
// TestClaim_JWTFieldBackwardCompat — the deprecated [ClaimOpts.JWT] field is
583+
// still accepted as a fallback so existing call sites compile unchanged, but
584+
// the wire body must still send the canonical `token` field.
585+
func TestClaim_JWTFieldBackwardCompat(t *testing.T) {
586+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
587+
body := decodeJSONBody(t, r.Body)
588+
if _, hasJWT := body["jwt"]; hasJWT {
589+
t.Errorf("body must not contain deprecated `jwt` field; got %+v", body)
590+
}
591+
if body["token"] != "legacy.jwt.val" {
592+
t.Errorf("token field = %v, want legacy.jwt.val", body["token"])
593+
}
594+
_ = json.NewEncoder(w).Encode(map[string]any{
595+
"ok": true, "team_id": "T", "user_id": "U", "message": "ok",
596+
})
597+
}))
598+
defer srv.Close()
599+
c := New(WithBaseURL(srv.URL))
600+
// Caller supplies legacy JWT field; SDK must translate to canonical wire.
601+
_, err := c.Claim(context.Background(), ClaimOpts{JWT: "legacy.jwt.val", Email: "a@b.c"})
602+
if err != nil {
603+
t.Fatalf("Claim: %v", err)
604+
}
605+
}
606+
607+
// TestClaim_TokenWinsOverJWT — when both fields are set, the canonical
608+
// Token field takes precedence (mirrors api ClaimRequest.claimToken).
609+
func TestClaim_TokenWinsOverJWT(t *testing.T) {
610+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
611+
body := decodeJSONBody(t, r.Body)
612+
if body["token"] != "canonical.tok" {
613+
t.Errorf("token = %v, want canonical.tok (Token must win over JWT)", body["token"])
614+
}
615+
_ = json.NewEncoder(w).Encode(map[string]any{
616+
"ok": true, "team_id": "T", "user_id": "U", "message": "ok",
617+
})
618+
}))
619+
defer srv.Close()
620+
c := New(WithBaseURL(srv.URL))
621+
_, err := c.Claim(context.Background(), ClaimOpts{
622+
Token: "canonical.tok",
623+
JWT: "deprecated.tok",
624+
Email: "a@b.c",
625+
})
626+
if err != nil {
627+
t.Fatalf("Claim: %v", err)
628+
}
629+
}
630+
569631
func TestClaimTokens(t *testing.T) {
570632
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
571633
if got := r.Header.Get("Authorization"); got != "Bearer sk-1" {

instant/example_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ func ExampleClient_Claim() {
7979
client := instant.New(instant.WithBaseURL("http://localhost:30080"))
8080

8181
result, err := client.Claim(ctx, instant.ClaimOpts{
82-
JWT: "eyJhbGci...", // from ?t= query param on the upgrade URL
82+
Token: "eyJhbGci...", // from ?t= query param on the upgrade URL
8383
Email: "dev@example.com",
8484
TeamName: "Acme Corp",
8585
})

instant/types.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,15 @@ type ClaimResult struct {
154154
// UserID is the UUID of the newly created user.
155155
UserID string `json:"user_id"`
156156

157+
// SessionToken is a freshly minted session JWT for the newly created
158+
// team, suitable for immediate use as the bearer token on follow-up
159+
// authenticated requests. Empty when the server elected not to mint one
160+
// (e.g. the [Client.ClaimTokens] path that supplies its own API key).
161+
//
162+
// 24h TTL; re-login on expiry (the API exposes no refresh endpoint).
163+
// Treat as a secret.
164+
SessionToken string `json:"session_token,omitempty"`
165+
157166
// Message is a human-readable confirmation message.
158167
Message string `json:"message"`
159168
}
@@ -195,9 +204,24 @@ type ProvisionOpts struct {
195204
}
196205

197206
// ClaimOpts are the parameters for the Claim method.
207+
//
208+
// Field-name policy (matches api ClaimRequest, 2026-05-20): Token is the
209+
// canonical onboarding-token field. JWT is the deprecated alias kept so
210+
// existing callers compile unchanged — when both are set, Token wins. New
211+
// code should set Token only. The SDK now sends the canonical `token` wire
212+
// field on every request, closing the three-name drift (jwt / token /
213+
// INSTANODE_TOKEN) the api ClaimRequest doc explicitly flags.
198214
type ClaimOpts struct {
199-
// JWT is the onboarding token obtained from the upgrade URL query parameter (required).
200-
JWT string `json:"jwt"`
215+
// Token is the canonical onboarding token obtained from the upgrade URL
216+
// query parameter "t" (required when JWT is unset).
217+
Token string `json:"token,omitempty"`
218+
219+
// JWT is the deprecated alias for Token. Provided for backward
220+
// compatibility with existing code; new callers should use Token.
221+
// When both are set, Token wins.
222+
//
223+
// Deprecated: use Token.
224+
JWT string `json:"-"`
201225

202226
// Email is the user's email address (required).
203227
Email string `json:"email"`
@@ -206,6 +230,17 @@ type ClaimOpts struct {
206230
TeamName string `json:"team_name,omitempty"`
207231
}
208232

233+
// claimToken returns the canonical onboarding token from a ClaimOpts,
234+
// preferring the new Token field and falling back to the deprecated JWT
235+
// field. Centralised so every read site agrees on the precedence (mirrors
236+
// api/internal/handlers/onboarding.go: ClaimRequest.claimToken).
237+
func (o ClaimOpts) claimToken() string {
238+
if o.Token != "" {
239+
return o.Token
240+
}
241+
return o.JWT
242+
}
243+
209244
// APIError is returned when the server responds with a 4xx or 5xx status code.
210245
// It implements the error interface so it can be used directly in error comparisons.
211246
type APIError struct {

instant_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -463,10 +463,11 @@ func TestClaim_Success(t *testing.T) {
463463
}
464464
var body map[string]string
465465
json.NewDecoder(r.Body).Decode(&body) //nolint:errcheck
466-
if body["jwt"] == "" || body["email"] == "" {
466+
// Canonical wire field is `token` (api ClaimRequest, 2026-05-20).
467+
if body["token"] == "" || body["email"] == "" {
467468
writeJSON(w, http.StatusBadRequest, map[string]any{
468-
"error": "missing_fields",
469-
"message": "jwt and email are required",
469+
"error": "missing_token",
470+
"message": "token and email are required",
470471
})
471472
return
472473
}
@@ -480,7 +481,7 @@ func TestClaim_Success(t *testing.T) {
480481

481482
client := serve(t, mux)
482483
result, err := client.Claim(context.Background(), instant.ClaimOpts{
483-
JWT: "test-jwt",
484+
Token: "test-jwt",
484485
Email: "dev@example.com",
485486
TeamName: "Test Corp",
486487
})

0 commit comments

Comments
 (0)