From a1f25c6d1b0343f26b8f97b609d7cbb5d9b450e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:33:27 +0000 Subject: [PATCH] feat(socrate): User.TokenVersion and User.Locked from the member look-up Socrate v1.8.0 returns token_version and locked on the single-member look-up (GET /api/apps/{id}/service/users/{user_id} and the user-token route). Expose them as optional pointer fields so a resource server can check user-token revocation with its cached service token. Nil from lists and older servers; additive. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GKRxaeYxyDhmt42cehLsGA --- CHANGELOG.md | 5 +++++ docs/CLIENT-INTEGRATION.md | 2 +- socrate/client.go | 10 ++++++++++ socrate/service_user_routes_test.go | 20 ++++++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a6d5..2b4b150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ All notable changes to backendkit are documented here. Format: the app's own identity. It is the cached token of the service-account calls (exchanged again within 30 s of expiry, one exchange for concurrent callers). Requested by Lakebridge (its consumer client takes Socrate service tokens without hand-rolling the OAuth exchange). +- `socrate.User.TokenVersion` and `socrate.User.Locked` (`*int`, `*bool`): set by `GetUser` and + `GetUserAsService` from Socrate v1.8.0, nil from lists and older servers. A user token whose + `token_version` claim is lower than `*TokenVersion` was revoked, so a resource server can check + revocation with its cached service token instead of introspecting every user token + (Lakebridge). ### Changed - A `client_credentials` response without an `access_token` is now an error instead of an empty diff --git a/docs/CLIENT-INTEGRATION.md b/docs/CLIENT-INTEGRATION.md index b21107b..10a96e9 100644 --- a/docs/CLIENT-INTEGRATION.md +++ b/docs/CLIENT-INTEGRATION.md @@ -455,7 +455,7 @@ automatically from `client_id` (cached). | `DeleteUser(ctx, userID)` | JWT | `error` | removes the user's role in this app. | | `ResendVerification(ctx, userID)` | JWT | `error` | re-sends the verification email. | | `ForcePasswordReset(ctx, userID)` | JWT | `error` | triggers a password-reset email. | -| `GetUserAsService(ctx, userID)` | M2M | `*User` | one of the app's members by numeric id (a token's `sub`); **nil,nil** when not a member (Socrate's 404). Needs a Socrate later than v1.5.3; an older one answers with an error, not nil,nil. | +| `GetUserAsService(ctx, userID)` | M2M | `*User` | one of the app's members by numeric id (a token's `sub`); **nil,nil** when not a member (Socrate's 404). Needs a Socrate later than v1.5.3; an older one answers with an error, not nil,nil. From Socrate v1.8.0 `User.TokenVersion` and `User.Locked` are set: a user token whose `token_version` claim is lower than `*TokenVersion` was revoked (sign-out, password change, block), a cheaper check than introspecting every token. | | `UpdateUserAsService(ctx, userID, UpdateProfileRequest)` | M2M | `*User` | updates profile fields of one of the app's members (name, phone, company, …, `AvatarURL`); never email, password or roles. The account is shared by every app on Socrate, so the change shows everywhere. `ErrUserNotInApp`, `ErrInvalidProfileUpdate` (Socrate's message wrapped). Needs Socrate v1.7.0. | | `RegisterUser(ctx, CreateUserRequest)` | M2M | `*CreateUserResult` | M2M create+invite, with `Name`; `ErrUserAlreadyExists` on 409. | | `InviteUserAsService(ctx, ServiceInviteRequest)` | M2M | `*CreateUserResult` | dedicated M2M invite route; no human JWT needed. | diff --git a/socrate/client.go b/socrate/client.go index 9ce5aad..70dbe93 100644 --- a/socrate/client.go +++ b/socrate/client.go @@ -388,6 +388,16 @@ type User struct { LastLogin *time.Time `json:"last_login,omitempty"` // AvatarURL is the member's picture (Socrate v1.7.0 or later); nil when unset. AvatarURL *string `json:"avatar_url,omitempty"` + // TokenVersion is the member's current token version (Socrate v1.8.0 or + // later, single-member look-ups GetUser and GetUserAsService only; nil from + // lists and older servers). A user token whose token_version claim is lower + // was revoked: sign-out, password change or reset, block, "revoke all + // tokens", or refresh-token reuse. A single token revoked through + // /oauth/revoke is not reflected; introspection is. + TokenVersion *int `json:"token_version,omitempty"` + // Locked reports whether the account is temporarily locked after failed + // sign-ins (same availability as TokenVersion). + Locked *bool `json:"locked,omitempty"` } // UserListResponse is the paginated list returned by ListUsers. diff --git a/socrate/service_user_routes_test.go b/socrate/service_user_routes_test.go index 82164a0..614967a 100644 --- a/socrate/service_user_routes_test.go +++ b/socrate/service_user_routes_test.go @@ -98,3 +98,23 @@ func TestGetUserAsService_MissingRouteIsAnError(t *testing.T) { t.Fatalf("missing route = %+v, %v; want an error naming the Socrate version", u, err) } } + +// Socrate v1.8.0 adds token_version and locked to the single-member look-up; +// an older server omits them and the fields stay nil. +func TestGetUserAsService_TokenVersionAndLocked(t *testing.T) { + c, _ := serviceRoutesServer(t, func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/7") { + _, _ = w.Write([]byte(`{"id":7,"email":"m@example.test","role":"user","token_version":4,"locked":false}`)) + return + } + _, _ = w.Write([]byte(`{"id":9,"email":"old@example.test","role":"user"}`)) + }) + u, err := c.GetUserAsService(context.Background(), "7") + if err != nil || u == nil || u.TokenVersion == nil || *u.TokenVersion != 4 || u.Locked == nil || *u.Locked { + t.Fatalf("GetUserAsService = %+v, %v; want token_version 4, locked false", u, err) + } + old, err := c.GetUserAsService(context.Background(), "9") + if err != nil || old == nil || old.TokenVersion != nil || old.Locked != nil { + t.Fatalf("older Socrate = %+v, %v; want nil TokenVersion and Locked", old, err) + } +}