@@ -16,10 +16,12 @@ use crate::transport;
1616// ---------------------------------------------------------------------------
1717
1818const AUTH_EXCHANGE_PATH : & str = "/api/auth/exchange" ;
19+ const AUTH_TOKEN_UPGRADE_PATH : & str = "/api/auth/token/upgrade" ;
1920const AUTH_REVOKE_PATH : & str = "/api/auth" ;
2021const BACKFILL_COMPLETE_PATH : & str = "/api/onboarding/backfill-complete" ;
2122const USER_ORGS_PATH : & str = "/api/user/orgs" ;
2223const SESSION_PUBLICATION_CREATE_PATH : & str = "/api/v2/session-publications" ;
24+ const TOKEN_UPGRADE_REQUIRED_ERROR : & str = "cli_token_upgrade_required" ;
2325
2426// ---------------------------------------------------------------------------
2527// Response DTOs
@@ -36,8 +38,9 @@ struct ExchangeRequest<'a> {
3638pub struct CliTokenExchangeResult {
3739 /// Bearer token used by the CLI for authenticated API calls.
3840 pub token : String ,
39- /// GitHub login associated with the authenticated user.
40- pub login : String ,
41+ /// Legacy GitHub login, returned by older API versions.
42+ #[ serde( default ) ]
43+ pub login : Option < String > ,
4144 /// RFC 3339 expiration timestamp for the token.
4245 pub expires_at : String ,
4346}
@@ -73,10 +76,15 @@ pub struct BackfillCompleteResponse {
7376/// Minimal org info from `GET /api/user/orgs`.
7477#[ derive( Debug , Clone , Deserialize , PartialEq , Eq ) ]
7578pub struct UserOrgInfo {
76- /// GitHub organization id for the account.
77- pub github_org_id : i64 ,
78- /// GitHub login for the org or personal account.
79- pub github_org_login : String ,
79+ /// Legacy GitHub organization id for the account, returned by older API versions.
80+ #[ serde( default ) ]
81+ pub github_org_id : Option < i64 > ,
82+ /// Provider-specific slug for the org or personal account.
83+ #[ serde( default , alias = "org_slug" ) ]
84+ pub github_org_login : Option < String > ,
85+ /// Provider that owns this org slug. Missing means legacy GitHub-shaped API response.
86+ #[ serde( default ) ]
87+ pub provider : Option < String > ,
8088 /// Display name shown to the user, when available.
8189 pub display_name : Option < String > ,
8290 /// Whether this record represents the user's personal account.
@@ -164,6 +172,8 @@ struct ApiResponseEnvelope<T> {
164172pub enum AuthenticatedRequestError {
165173 /// Authentication failed or the token has expired.
166174 Unauthorized ,
175+ /// The API requires this CLI token to be upgraded before retrying.
176+ TokenUpgradeRequired ( String ) ,
167177 /// The request conflicted with current server state.
168178 Conflict ( String ) ,
169179 /// The target resource could not be found.
@@ -186,6 +196,7 @@ impl std::fmt::Display for AuthenticatedRequestError {
186196 fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
187197 match self {
188198 Self :: Unauthorized => write ! ( f, "unauthorized" ) ,
199+ Self :: TokenUpgradeRequired ( msg) => write ! ( f, "token_upgrade_required: {msg}" ) ,
189200 Self :: Conflict ( msg) => write ! ( f, "conflict: {msg}" ) ,
190201 Self :: NotFound => write ! ( f, "not_found" ) ,
191202 Self :: Unprocessable ( msg) => write ! ( f, "unprocessable: {msg}" ) ,
@@ -271,6 +282,34 @@ impl ApiClient {
271282 Ok ( envelope. data )
272283 }
273284
285+ /// Upgrade an existing CLI token to the current API JWT shape.
286+ pub async fn upgrade_cli_token (
287+ & self ,
288+ token : & str ,
289+ timeout : Duration ,
290+ ) -> std:: result:: Result < CliTokenExchangeResult , AuthenticatedRequestError > {
291+ let url = self . url ( AUTH_TOKEN_UPGRADE_PATH ) ;
292+ let resp = self
293+ . client
294+ . post ( & url)
295+ . bearer_auth ( token)
296+ . timeout ( timeout)
297+ . send ( )
298+ . await
299+ . map_err ( map_network_error) ?;
300+
301+ if !resp. status ( ) . is_success ( ) {
302+ let status = resp. status ( ) . as_u16 ( ) ;
303+ let body = resp. text ( ) . await . unwrap_or_default ( ) ;
304+ return Err ( map_authenticated_http_error ( status, & body) ) ;
305+ }
306+
307+ let body = resp. text ( ) . await . map_err ( map_network_error) ?;
308+ let envelope: ApiResponseEnvelope < CliTokenExchangeResult > = serde_json:: from_str ( & body)
309+ . map_err ( |e| AuthenticatedRequestError :: Parse ( e. to_string ( ) ) ) ?;
310+ Ok ( envelope. data )
311+ }
312+
274313 /// Revoke a bearer token via `DELETE /api/auth`.
275314 pub async fn revoke_token (
276315 & self ,
@@ -505,6 +544,9 @@ fn map_authenticated_http_error(status: u16, body: &str) -> AuthenticatedRequest
505544 400 => AuthenticatedRequestError :: BadRequest ( detail) ,
506545 404 => AuthenticatedRequestError :: NotFound ,
507546 409 => AuthenticatedRequestError :: Conflict ( detail) ,
547+ 422 if extract_error_code ( body) . as_deref ( ) == Some ( TOKEN_UPGRADE_REQUIRED_ERROR ) => {
548+ AuthenticatedRequestError :: TokenUpgradeRequired ( detail)
549+ }
508550 422 => AuthenticatedRequestError :: Unprocessable ( detail) ,
509551 500 ..=599 => AuthenticatedRequestError :: Server ( detail) ,
510552 _ => AuthenticatedRequestError :: Unexpected ( format ! ( "HTTP {status}: {detail}" ) ) ,
@@ -565,6 +607,12 @@ fn extract_error_message(body: &str) -> String {
565607 }
566608}
567609
610+ fn extract_error_code ( body : & str ) -> Option < String > {
611+ serde_json:: from_str :: < serde_json:: Value > ( body)
612+ . ok ( )
613+ . and_then ( |value| value. get ( "error" ) ?. as_str ( ) . map ( ToString :: to_string) )
614+ }
615+
568616// ---------------------------------------------------------------------------
569617// Tests
570618// ---------------------------------------------------------------------------
@@ -596,6 +644,13 @@ mod tests {
596644 map_authenticated_http_error( 422 , "" ) ,
597645 AuthenticatedRequestError :: Unprocessable ( _)
598646 ) ) ;
647+ assert ! ( matches!(
648+ map_authenticated_http_error(
649+ 422 ,
650+ r#"{"error":"cli_token_upgrade_required","message":"upgrade me"}"#
651+ ) ,
652+ AuthenticatedRequestError :: TokenUpgradeRequired ( _)
653+ ) ) ;
599654 assert ! ( matches!(
600655 map_authenticated_http_error( 404 , "" ) ,
601656 AuthenticatedRequestError :: NotFound
@@ -606,6 +661,44 @@ mod tests {
606661 ) ) ;
607662 }
608663
664+ #[ test]
665+ fn cli_token_exchange_result_allows_missing_legacy_login ( ) {
666+ let envelope: ApiResponseEnvelope < CliTokenExchangeResult > = serde_json:: from_str (
667+ r#"{"data":{"token":"token","expires_at":"2099-01-01T00:00:00Z"}}"# ,
668+ )
669+ . expect ( "parse token exchange envelope" ) ;
670+
671+ assert_eq ! ( envelope. data. token, "token" ) ;
672+ assert_eq ! ( envelope. data. login, None ) ;
673+ assert_eq ! ( envelope. data. expires_at, "2099-01-01T00:00:00Z" ) ;
674+ }
675+
676+ #[ test]
677+ fn user_orgs_response_allows_current_api_org_slug_shape ( ) {
678+ let envelope: ApiResponseEnvelope < UserOrgsResponse > = serde_json:: from_str (
679+ r#"{"data":{"orgs":[{
680+ "org_slug":"cadence-org",
681+ "provider":"github",
682+ "display_name":"Cadence Org",
683+ "avatar_url":"https://example.test/avatar.png",
684+ "is_personal":false,
685+ "is_onboarded":true,
686+ "has_active_installation":true,
687+ "org_id":"org-1",
688+ "role":"manager",
689+ "onboarding_hint":"ready"
690+ }]}}"# ,
691+ )
692+ . expect ( "parse current user orgs envelope" ) ;
693+
694+ let org = & envelope. data . orgs [ 0 ] ;
695+ assert_eq ! ( org. github_org_id, None ) ;
696+ assert_eq ! ( org. github_org_login. as_deref( ) , Some ( "cadence-org" ) ) ;
697+ assert_eq ! ( org. provider. as_deref( ) , Some ( "github" ) ) ;
698+ assert_eq ! ( org. org_id. as_deref( ) , Some ( "org-1" ) ) ;
699+ assert ! ( org. has_active_installation) ;
700+ }
701+
609702 #[ test]
610703 fn session_upload_request_serializes_upload_sha256_field ( ) {
611704 let request = CreateSessionPublicationRequest {
0 commit comments