@@ -253,6 +253,15 @@ impl PyxTokenStore {
253253 } )
254254 }
255255
256+ /// Extract the workspace name from a pyx Simple API URL.
257+ ///
258+ /// Pyx Simple API index URLs have the form `{api}/simple/{workspace}/{view}` (e.g.,
259+ /// `https://api.pyx.dev/simple/acme/main`). Returns `None` if the URL does not match this
260+ /// store's API URL or does not have the expected path shape.
261+ pub fn workspace_for_url < ' a > ( & self , url : & ' a DisplaySafeUrl ) -> Option < & ' a str > {
262+ workspace_from_simple_url ( url, & self . api )
263+ }
264+
256265 /// Return the root directory for the token store.
257266 pub fn root ( & self ) -> & Path {
258267 & self . root
@@ -271,18 +280,22 @@ impl PyxTokenStore {
271280 ///
272281 /// If no access token is found, but an API key is present, the API key will be used to
273282 /// bootstrap an access token.
283+ ///
284+ /// `workspace` is the pyx workspace name, required to bootstrap a Trusted Access token. If
285+ /// `None`, Trusted Access bootstrapping is skipped.
274286 pub async fn access_token (
275287 & self ,
276288 client : & ClientWithMiddleware ,
277289 tolerance_secs : u64 ,
290+ workspace : Option < & str > ,
278291 ) -> Result < Option < AccessToken > , TokenStoreError > {
279292 // If the access token is already set in the environment, return it.
280293 if let Some ( access_token) = read_pyx_auth_token ( ) {
281294 return Ok ( Some ( access_token) ) ;
282295 }
283296
284297 // Initialize the tokens from the store.
285- let tokens = self . init ( client, tolerance_secs) . await ?;
298+ let tokens = self . init ( client, tolerance_secs, workspace ) . await ?;
286299
287300 // Extract the access token from the OAuth tokens or API key.
288301 Ok ( tokens. map ( AccessToken :: from) )
@@ -294,20 +307,26 @@ impl PyxTokenStore {
294307 ///
295308 /// If no access token is found, but an API key is present, the API key will be used to
296309 /// bootstrap an access token.
310+ ///
311+ /// `workspace` is the pyx workspace name, required to bootstrap a Trusted Access token. If
312+ /// `None`, Trusted Access bootstrapping is skipped.
297313 pub async fn init (
298314 & self ,
299315 client : & ClientWithMiddleware ,
300316 tolerance_secs : u64 ,
317+ workspace : Option < & str > ,
301318 ) -> Result < Option < PyxTokens > , TokenStoreError > {
302319 match self . read ( ) . await ? {
303320 Some ( tokens) => {
304321 // Refresh the tokens if they are expired.
305- let tokens = self . refresh ( tokens, client, tolerance_secs) . await ?;
322+ let tokens = self
323+ . refresh ( tokens, client, tolerance_secs, workspace)
324+ . await ?;
306325 Ok ( Some ( tokens) )
307326 }
308327 None => {
309- // If no tokens are present, bootstrap them from an API key.
310- self . bootstrap ( client) . await
328+ // If no tokens are present, bootstrap them from an API key or Trusted Access .
329+ self . bootstrap ( client, workspace ) . await
311330 }
312331 }
313332 }
@@ -412,23 +431,31 @@ impl PyxTokenStore {
412431 }
413432
414433 /// Bootstrap the tokens from an API key or from Trusted Access.
434+ ///
435+ /// `workspace` is the pyx workspace name, required to bootstrap a Trusted Access token. If
436+ /// `None`, Trusted Access bootstrapping is skipped.
415437 async fn bootstrap (
416438 & self ,
417439 client : & ClientWithMiddleware ,
440+ workspace : Option < & str > ,
418441 ) -> Result < Option < PyxTokens > , TokenStoreError > {
419442 if let Some ( api_key) = read_pyx_api_key ( ) {
420443 // If an API key is present, use it to bootstrap the tokens.
421444 self . bootstrap_from_api_key ( api_key, client) . await . map ( Some )
422445 } else {
423446 // Otherwise, attempt to bootstrap from Trusted Access.
424- self . bootstrap_from_trusted_access ( client) . await
447+ self . bootstrap_from_trusted_access ( client, workspace ) . await
425448 }
426449 }
427450
428451 /// Bootstrap a [`PyxTokens`] from Trusted Access.
452+ ///
453+ /// `workspace` is the pyx workspace name. If `None`, Trusted Access bootstrapping is skipped,
454+ /// since the workspace is required to construct the token endpoint.
429455 async fn bootstrap_from_trusted_access (
430456 & self ,
431457 client : & ClientWithMiddleware ,
458+ workspace : Option < & str > ,
432459 ) -> Result < Option < PyxTokens > , TokenStoreError > {
433460 #[ derive( Debug , Clone , serde:: Serialize ) ]
434461 struct RequestBody < ' a > {
@@ -440,6 +467,12 @@ impl PyxTokenStore {
440467 token : AccessToken ,
441468 }
442469
470+ // Trusted Access requires the workspace name to construct the token endpoint.
471+ let Some ( workspace) = workspace else {
472+ debug ! ( "Skipping Trusted Access: pyx workspace name is not known" ) ;
473+ return Ok ( None ) ;
474+ } ;
475+
443476 // Get an OIDC token from the ambient environment (e.g., GitHub Actions).
444477 let detector = ambient_id:: Detector :: new_with_client ( client. clone ( ) ) ;
445478 let Some ( id_token) = detector. detect ( "pyx:trusted-access" ) . await ? else {
@@ -449,7 +482,7 @@ impl PyxTokenStore {
449482
450483 // Exchange the OIDC token for a Trusted Access token from pyx.
451484 let mut url = self . api . clone ( ) ;
452- url. set_path ( "v1/trusted-access/TODO /mint-token" ) ;
485+ url. set_path ( & format ! ( "v1/trusted-access/{workspace} /mint-token" ) ) ;
453486
454487 let request = client
455488 . request ( reqwest:: Method :: POST , Url :: from ( url) )
@@ -509,6 +542,7 @@ impl PyxTokenStore {
509542 tokens : PyxTokens ,
510543 client : & ClientWithMiddleware ,
511544 tolerance_secs : u64 ,
545+ workspace : Option < & str > ,
512546 ) -> Result < PyxTokens , TokenStoreError > {
513547 let reason = match tokens. check_fresh ( tolerance_secs) {
514548 Ok ( exp) => {
@@ -589,7 +623,7 @@ impl PyxTokenStore {
589623 }
590624 PyxTokens :: TrustedAccess ( _) => {
591625 // Refreshing a Trusted Access token is the same as bootstrapping it.
592- self . bootstrap_from_trusted_access ( client)
626+ self . bootstrap_from_trusted_access ( client, workspace )
593627 . await ?
594628 . ok_or_else ( || {
595629 // This can only happen if we're in an environment that previously
@@ -729,6 +763,30 @@ fn is_known_domain(url: &Url, api: &DisplaySafeUrl, cdn: &str) -> bool {
729763 is_known_url ( url, api, cdn)
730764}
731765
766+ /// Extract the workspace name from a pyx Simple API URL.
767+ ///
768+ /// Pyx Simple API URLs have the form `{api}/simple/{workspace}/{view}` (e.g.,
769+ /// `https://api.pyx.dev/simple/acme/main`). Returns the workspace segment when
770+ /// the URL matches the given `api` base URL.
771+ fn workspace_from_simple_url < ' a > ( url : & ' a DisplaySafeUrl , api : & DisplaySafeUrl ) -> Option < & ' a str > {
772+ // The URL must be on the same host/port as the API.
773+ if url. scheme ( ) != api. scheme ( )
774+ || url. host_str ( ) != api. host_str ( )
775+ || url. port_or_known_default ( ) != api. port_or_known_default ( )
776+ {
777+ return None ;
778+ }
779+ // Path must be `/simple/{workspace}/{view}[/]`.
780+ let mut segments = url. path_segments ( ) ?;
781+ if segments. next ( ) ? != "simple" {
782+ return None ;
783+ }
784+ let workspace = segments. next ( ) . filter ( |s| !s. is_empty ( ) ) ?;
785+ // There must be at least a view segment after the workspace.
786+ segments. next ( ) . filter ( |s| !s. is_empty ( ) ) ?;
787+ Some ( workspace)
788+ }
789+
732790/// Returns `true` if the URL is on the default pyx domain.
733791///
734792/// This is used in auth commands to recognize `pyx.dev` as a pyx domain even when
@@ -943,4 +1001,73 @@ mod tests {
9431001 & Url :: parse( "https://beta.pyx.dev" ) . unwrap( )
9441002 ) ) ;
9451003 }
1004+
1005+ #[ test]
1006+ fn test_workspace_from_simple_url ( ) {
1007+ let api = DisplaySafeUrl :: parse ( "https://api.pyx.dev" ) . unwrap ( ) ;
1008+
1009+ // Standard pyx simple index URL.
1010+ assert_eq ! (
1011+ workspace_from_simple_url(
1012+ & DisplaySafeUrl :: parse( "https://api.pyx.dev/simple/acme/main" ) . unwrap( ) ,
1013+ & api
1014+ ) ,
1015+ Some ( "acme" )
1016+ ) ;
1017+
1018+ // Trailing slash is fine.
1019+ assert_eq ! (
1020+ workspace_from_simple_url(
1021+ & DisplaySafeUrl :: parse( "https://api.pyx.dev/simple/acme/main/" ) . unwrap( ) ,
1022+ & api
1023+ ) ,
1024+ Some ( "acme" )
1025+ ) ;
1026+
1027+ // Must have a view segment after the workspace (bare /simple/acme is not a full index URL).
1028+ assert_eq ! (
1029+ workspace_from_simple_url(
1030+ & DisplaySafeUrl :: parse( "https://api.pyx.dev/simple/acme" ) . unwrap( ) ,
1031+ & api
1032+ ) ,
1033+ None
1034+ ) ;
1035+
1036+ // Non-simple path returns None.
1037+ assert_eq ! (
1038+ workspace_from_simple_url(
1039+ & DisplaySafeUrl :: parse( "https://api.pyx.dev/v1/upload/acme/main" ) . unwrap( ) ,
1040+ & api
1041+ ) ,
1042+ None
1043+ ) ;
1044+
1045+ // Different host returns None.
1046+ assert_eq ! (
1047+ workspace_from_simple_url(
1048+ & DisplaySafeUrl :: parse( "https://other.pyx.dev/simple/acme/main" ) . unwrap( ) ,
1049+ & api
1050+ ) ,
1051+ None
1052+ ) ;
1053+
1054+ // Different scheme returns None.
1055+ assert_eq ! (
1056+ workspace_from_simple_url(
1057+ & DisplaySafeUrl :: parse( "http://api.pyx.dev/simple/acme/main" ) . unwrap( ) ,
1058+ & api
1059+ ) ,
1060+ None
1061+ ) ;
1062+
1063+ // Custom API URL works the same way.
1064+ let custom_api = DisplaySafeUrl :: parse ( "https://staging.example.com" ) . unwrap ( ) ;
1065+ assert_eq ! (
1066+ workspace_from_simple_url(
1067+ & DisplaySafeUrl :: parse( "https://staging.example.com/simple/myorg/prod" ) . unwrap( ) ,
1068+ & custom_api
1069+ ) ,
1070+ Some ( "myorg" )
1071+ ) ;
1072+ }
9461073}
0 commit comments