From 47c70404edc30b920a89326dda89bac9ad00ea90 Mon Sep 17 00:00:00 2001 From: koraytt Date: Sat, 9 May 2026 15:56:32 +0300 Subject: [PATCH 1/9] fix: look up interactive context value with the typed key PR #466 introduced a typed contextKey (interactiveKey) for the interactive flag but auth.go and download.go still queried the context with the raw string "interactive", which never matches a value stored under a contextKey-typed key. As a result, auth login panicked on a nil-to-bool type assertion the first time it ran post-#466, and download silently treated the session as non-interactive (no progress bar). Switch both call sites to look up via interactiveKey and use the comma-ok form of the type assertion to avoid the panic. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/auth.go | 2 +- cmd/download.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/auth.go b/cmd/auth.go index 34e1d4a2..0adf2e78 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -47,7 +47,7 @@ func loginCmd() *cobra.Command { Use: "login", Short: "Login to the App Store", RunE: func(cmd *cobra.Command, args []string) error { - interactive := cmd.Context().Value("interactive").(bool) + interactive, _ := cmd.Context().Value(interactiveKey).(bool) if password == "" && !interactive { return errors.New("password is required when not running in interactive mode; use the \"--password\" flag") diff --git a/cmd/download.go b/cmd/download.go index 0df1d366..30d7693c 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -81,7 +81,7 @@ func downloadCmd() *cobra.Command { Msg("purchase") } - interactive, _ := cmd.Context().Value("interactive").(bool) + interactive, _ := cmd.Context().Value(interactiveKey).(bool) var progress *progressbar.ProgressBar if interactive { progress = progressbar.NewOptions64(1, From 3aa4a86febe9ee056b04b4d90ee5f62afaa31cc8 Mon Sep 17 00:00:00 2001 From: koraytt Date: Sat, 9 May 2026 15:56:50 +0300 Subject: [PATCH 2/9] fix: fall back to redownload endpoint when MZFinance returns 5002 (#464) Apple's volumeStoreDownloadProduct path on buy.itunes.apple.com (the endpoint ipatool drives for download, list-versions, and get-version-metadata) now responds with FailureType 5002 and Items=null for a number of apps the calling account already owns -- Microsoft Teams, the Office bundle, Spotify, Facebook, etc. The bag advertises a separate redownload endpoint at https://downloaddispatch.itunes.apple.com/r/redownload that accepts the same plist payload (with appExtVrsId in place of externalVersionId for version pinning) and returns the full download metadata for those apps. When the primary request comes back with FailureType 5002, retry the same call against the redownload endpoint and use that response. If the fallback also fails, surface its customerMessage in the empty-Items branch so users see a meaningful error (e.g. "Redownload Unavailable with This Apple Account") instead of "invalid response." Also stop mapping FailureType 5002 to ErrPasswordTokenExpired in the download flow. The mapping was added in #468 on the assumption that the condition was a stale token, but re-authenticating doesn't change Apple's response for the affected apps; with the redownload fallback in place the false re-login retry only obscures the real outcome. Verified end-to-end: list-versions and get-version-metadata return the full version history for com.microsoft.skype.teams, and download produces a valid 366 MB .ipa with the Microsoft Teams app bundle intact. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/appstore/appstore_download.go | 46 ++++++++++++++++- pkg/appstore/appstore_download_test.go | 37 ++++++++++++++ pkg/appstore/appstore_get_version_metadata.go | 13 +++++ .../appstore_get_version_metadata_test.go | 37 ++++++++++++++ pkg/appstore/appstore_list_versions.go | 13 +++++ pkg/appstore/appstore_list_versions_test.go | 51 +++++++++++++++++++ pkg/appstore/constants.go | 3 ++ 7 files changed, 198 insertions(+), 2 deletions(-) diff --git a/pkg/appstore/appstore_download.go b/pkg/appstore/appstore_download.go index 4e714dab..ae21a09c 100644 --- a/pkg/appstore/appstore_download.go +++ b/pkg/appstore/appstore_download.go @@ -46,10 +46,18 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { return DownloadOutput{}, fmt.Errorf("failed to send http request: %w", err) } + if res.Data.FailureType == FailureTypeLicenseAlreadyExists { + req = t.redownloadRequest(input.Account, input.App, guid, input.ExternalVersionID) + + res, err = t.downloadClient.Send(req) + if err != nil { + return DownloadOutput{}, fmt.Errorf("failed to send http request: %w", err) + } + } + if res.Data.FailureType == FailureTypePasswordTokenExpired || res.Data.FailureType == FailureTypeSignInRequired || - res.Data.FailureType == FailureTypeDeviceVerificationFailed || - res.Data.FailureType == FailureTypeLicenseAlreadyExists { + res.Data.FailureType == FailureTypeDeviceVerificationFailed { return DownloadOutput{}, ErrPasswordTokenExpired } @@ -66,6 +74,10 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { } if len(res.Data.Items) == 0 { + if res.Data.CustomerMessage != "" { + return DownloadOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res) + } + return DownloadOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res) } @@ -203,6 +215,36 @@ func (*appstore) downloadRequest(acc Account, app App, guid string, externalVers } } +// redownloadRequest builds a request against the redownload dispatcher endpoint. +// Some apps (notably Microsoft Teams/Office and other VPP-eligible apps) reject +// the volumeStoreDownloadProduct path with FailureType 5002, but accept this +// per-account redownload endpoint when the user already owns a license. +func (*appstore) redownloadRequest(acc Account, app App, guid string, externalVersionID string) http.Request { + payload := map[string]interface{}{ + "creditDisplay": "", + "guid": guid, + "salableAdamId": app.ID, + } + + if externalVersionID != "" { + payload["appExtVrsId"] = externalVersionID + } + + return http.Request{ + URL: fmt.Sprintf("https://%s%s", PrivateDownloadDispatchAPIDomain, PrivateDownloadDispatchAPIPath), + Method: http.MethodPOST, + ResponseFormat: http.ResponseFormatXML, + Headers: map[string]string{ + "Content-Type": "application/x-apple-plist", + "iCloud-DSID": acc.DirectoryServicesID, + "X-Dsid": acc.DirectoryServicesID, + }, + Payload: &http.XMLPayload{ + Content: payload, + }, + } +} + func fileName(app App, version string) string { var parts []string diff --git a/pkg/appstore/appstore_download_test.go b/pkg/appstore/appstore_download_test.go index f5fb8f90..b6948250 100644 --- a/pkg/appstore/appstore_download_test.go +++ b/pkg/appstore/appstore_download_test.go @@ -190,6 +190,43 @@ var _ = Describe("AppStore (Download)", func() { }) }) + When("primary endpoint returns FailureType 5002", func() { + BeforeEach(func() { + mockMachine.EXPECT(). + MacAddress(). + Return("", nil) + + gomock.InOrder( + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(ContainSubstring(PrivateAppStoreAPIPathDownload)) + }). + Return(http.Result[downloadResult]{ + Data: downloadResult{ + FailureType: FailureTypeLicenseAlreadyExists, + }, + }, nil), + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(ContainSubstring(PrivateDownloadDispatchAPIDomain)) + }). + Return(http.Result[downloadResult]{ + Data: downloadResult{ + FailureType: "secondary-failure", + }, + }, nil), + ) + }) + + It("falls back to the redownload endpoint and surfaces its error", func() { + _, err := as.Download(DownloadInput{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("secondary-failure")) + }) + }) + When("store API returns error", func() { BeforeEach(func() { mockMachine.EXPECT(). diff --git a/pkg/appstore/appstore_get_version_metadata.go b/pkg/appstore/appstore_get_version_metadata.go index efa182be..64ecabc8 100644 --- a/pkg/appstore/appstore_get_version_metadata.go +++ b/pkg/appstore/appstore_get_version_metadata.go @@ -35,6 +35,15 @@ func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersion return GetVersionMetadataOutput{}, fmt.Errorf("failed to send http request: %w", err) } + if res.Data.FailureType == FailureTypeLicenseAlreadyExists { + req = t.redownloadRequest(input.Account, input.App, guid, input.VersionID) + + res, err = t.downloadClient.Send(req) + if err != nil { + return GetVersionMetadataOutput{}, fmt.Errorf("failed to send http request: %w", err) + } + } + if res.Data.FailureType == FailureTypePasswordTokenExpired || res.Data.FailureType == FailureTypeSignInRequired { return GetVersionMetadataOutput{}, ErrPasswordTokenExpired } @@ -52,6 +61,10 @@ func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersion } if len(res.Data.Items) == 0 { + if res.Data.CustomerMessage != "" { + return GetVersionMetadataOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res) + } + return GetVersionMetadataOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res) } diff --git a/pkg/appstore/appstore_get_version_metadata_test.go b/pkg/appstore/appstore_get_version_metadata_test.go index 8c0a72a8..6e85b587 100644 --- a/pkg/appstore/appstore_get_version_metadata_test.go +++ b/pkg/appstore/appstore_get_version_metadata_test.go @@ -316,6 +316,43 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() { }) }) + When("primary endpoint returns FailureType 5002", func() { + BeforeEach(func() { + mockMachine.EXPECT(). + MacAddress(). + Return("00:11:22:33:44:55", nil) + + gomock.InOrder( + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(ContainSubstring(PrivateAppStoreAPIPathDownload)) + }). + Return(http.Result[downloadResult]{ + Data: downloadResult{ + FailureType: FailureTypeLicenseAlreadyExists, + }, + }, nil), + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(ContainSubstring(PrivateDownloadDispatchAPIDomain)) + }). + Return(http.Result[downloadResult]{ + Data: downloadResult{ + FailureType: "secondary-failure", + }, + }, nil), + ) + }) + + It("falls back to the redownload endpoint and surfaces its error", func() { + _, err := as.GetVersionMetadata(GetVersionMetadataInput{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("secondary-failure")) + }) + }) + When("store API returns error", func() { BeforeEach(func() { mockMachine.EXPECT(). diff --git a/pkg/appstore/appstore_list_versions.go b/pkg/appstore/appstore_list_versions.go index dc3c215d..1359e69d 100644 --- a/pkg/appstore/appstore_list_versions.go +++ b/pkg/appstore/appstore_list_versions.go @@ -33,6 +33,15 @@ func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, er return ListVersionsOutput{}, fmt.Errorf("failed to send http request: %w", err) } + if res.Data.FailureType == FailureTypeLicenseAlreadyExists { + req = t.redownloadRequest(input.Account, input.App, guid, "") + + res, err = t.downloadClient.Send(req) + if err != nil { + return ListVersionsOutput{}, fmt.Errorf("failed to send http request: %w", err) + } + } + if res.Data.FailureType == FailureTypePasswordTokenExpired || res.Data.FailureType == FailureTypeSignInRequired { return ListVersionsOutput{}, ErrPasswordTokenExpired } @@ -50,6 +59,10 @@ func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, er } if len(res.Data.Items) == 0 { + if res.Data.CustomerMessage != "" { + return ListVersionsOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res) + } + return ListVersionsOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res) } diff --git a/pkg/appstore/appstore_list_versions_test.go b/pkg/appstore/appstore_list_versions_test.go index 7b4fb67a..64d0a3b6 100644 --- a/pkg/appstore/appstore_list_versions_test.go +++ b/pkg/appstore/appstore_list_versions_test.go @@ -277,6 +277,57 @@ var _ = Describe("AppStore (ListVersions)", func() { }) }) + When("primary endpoint returns FailureType 5002", func() { + const ( + testVersion1 = "12345678" + testVersion2 = "87654321" + testLatest = "87654321" + ) + + BeforeEach(func() { + mockMachine.EXPECT(). + MacAddress(). + Return("00:00:00:00:00:00", nil) + + gomock.InOrder( + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(ContainSubstring(PrivateAppStoreAPIPathDownload)) + }). + Return(http.Result[downloadResult]{ + Data: downloadResult{ + FailureType: FailureTypeLicenseAlreadyExists, + }, + }, nil), + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(ContainSubstring(PrivateDownloadDispatchAPIDomain)) + }). + Return(http.Result[downloadResult]{ + Data: downloadResult{ + Items: []downloadItemResult{ + { + Metadata: map[string]interface{}{ + "softwareVersionExternalIdentifiers": []interface{}{testVersion1, testVersion2}, + "softwareVersionExternalIdentifier": testLatest, + }, + }, + }, + }, + }, nil), + ) + }) + + It("falls back to the redownload endpoint and returns versions", func() { + out, err := as.ListVersions(ListVersionsInput{}) + Expect(err).ToNot(HaveOccurred()) + Expect(out.ExternalVersionIdentifiers).To(Equal([]string{testVersion1, testVersion2})) + Expect(out.LatestExternalVersionID).To(Equal(testLatest)) + }) + }) + When("successfully lists versions", func() { const ( testVersion1 = "12345678" diff --git a/pkg/appstore/constants.go b/pkg/appstore/constants.go index 66201514..0fa616b2 100644 --- a/pkg/appstore/constants.go +++ b/pkg/appstore/constants.go @@ -25,6 +25,9 @@ const ( PrivateAppStoreAPIPathPurchase = "/WebObjects/MZFinance.woa/wa/buyProduct" PrivateAppStoreAPIPathDownload = "/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct" + PrivateDownloadDispatchAPIDomain = "downloaddispatch." + iTunesAPIDomain + PrivateDownloadDispatchAPIPath = "/r/redownload" + HTTPHeaderStoreFront = "X-Set-Apple-Store-Front" HTTPHeaderPod = "pod" From f366fc131d422b080aca30e075eb46a0bfdae824 Mon Sep 17 00:00:00 2001 From: koraytutuncu <74398122+koraytutuncu@users.noreply.github.com> Date: Mon, 11 May 2026 13:12:48 +0300 Subject: [PATCH 3/9] Revert PR #477 changes to restore base before reimplementation Reverts both commits that previously sat on this branch: - 3aa4a86 "fix: fall back to redownload endpoint when MZFinance returns 5002 (#464)" - 47c7040 "fix: look up interactive context value with the typed key" After this commit the tree is identical to majd/ipatool main (85ae82d), clearing the way for the bag-resolved approach to land as a clean series of focused commits without force-pushing the branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/auth.go | 2 +- cmd/download.go | 2 +- pkg/appstore/appstore_download.go | 46 +---------------- pkg/appstore/appstore_download_test.go | 37 -------------- pkg/appstore/appstore_get_version_metadata.go | 13 ----- .../appstore_get_version_metadata_test.go | 37 -------------- pkg/appstore/appstore_list_versions.go | 13 ----- pkg/appstore/appstore_list_versions_test.go | 51 ------------------- pkg/appstore/constants.go | 3 -- 9 files changed, 4 insertions(+), 200 deletions(-) diff --git a/cmd/auth.go b/cmd/auth.go index 0adf2e78..34e1d4a2 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -47,7 +47,7 @@ func loginCmd() *cobra.Command { Use: "login", Short: "Login to the App Store", RunE: func(cmd *cobra.Command, args []string) error { - interactive, _ := cmd.Context().Value(interactiveKey).(bool) + interactive := cmd.Context().Value("interactive").(bool) if password == "" && !interactive { return errors.New("password is required when not running in interactive mode; use the \"--password\" flag") diff --git a/cmd/download.go b/cmd/download.go index 30d7693c..0df1d366 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -81,7 +81,7 @@ func downloadCmd() *cobra.Command { Msg("purchase") } - interactive, _ := cmd.Context().Value(interactiveKey).(bool) + interactive, _ := cmd.Context().Value("interactive").(bool) var progress *progressbar.ProgressBar if interactive { progress = progressbar.NewOptions64(1, diff --git a/pkg/appstore/appstore_download.go b/pkg/appstore/appstore_download.go index ae21a09c..4e714dab 100644 --- a/pkg/appstore/appstore_download.go +++ b/pkg/appstore/appstore_download.go @@ -46,18 +46,10 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { return DownloadOutput{}, fmt.Errorf("failed to send http request: %w", err) } - if res.Data.FailureType == FailureTypeLicenseAlreadyExists { - req = t.redownloadRequest(input.Account, input.App, guid, input.ExternalVersionID) - - res, err = t.downloadClient.Send(req) - if err != nil { - return DownloadOutput{}, fmt.Errorf("failed to send http request: %w", err) - } - } - if res.Data.FailureType == FailureTypePasswordTokenExpired || res.Data.FailureType == FailureTypeSignInRequired || - res.Data.FailureType == FailureTypeDeviceVerificationFailed { + res.Data.FailureType == FailureTypeDeviceVerificationFailed || + res.Data.FailureType == FailureTypeLicenseAlreadyExists { return DownloadOutput{}, ErrPasswordTokenExpired } @@ -74,10 +66,6 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { } if len(res.Data.Items) == 0 { - if res.Data.CustomerMessage != "" { - return DownloadOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res) - } - return DownloadOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res) } @@ -215,36 +203,6 @@ func (*appstore) downloadRequest(acc Account, app App, guid string, externalVers } } -// redownloadRequest builds a request against the redownload dispatcher endpoint. -// Some apps (notably Microsoft Teams/Office and other VPP-eligible apps) reject -// the volumeStoreDownloadProduct path with FailureType 5002, but accept this -// per-account redownload endpoint when the user already owns a license. -func (*appstore) redownloadRequest(acc Account, app App, guid string, externalVersionID string) http.Request { - payload := map[string]interface{}{ - "creditDisplay": "", - "guid": guid, - "salableAdamId": app.ID, - } - - if externalVersionID != "" { - payload["appExtVrsId"] = externalVersionID - } - - return http.Request{ - URL: fmt.Sprintf("https://%s%s", PrivateDownloadDispatchAPIDomain, PrivateDownloadDispatchAPIPath), - Method: http.MethodPOST, - ResponseFormat: http.ResponseFormatXML, - Headers: map[string]string{ - "Content-Type": "application/x-apple-plist", - "iCloud-DSID": acc.DirectoryServicesID, - "X-Dsid": acc.DirectoryServicesID, - }, - Payload: &http.XMLPayload{ - Content: payload, - }, - } -} - func fileName(app App, version string) string { var parts []string diff --git a/pkg/appstore/appstore_download_test.go b/pkg/appstore/appstore_download_test.go index b6948250..f5fb8f90 100644 --- a/pkg/appstore/appstore_download_test.go +++ b/pkg/appstore/appstore_download_test.go @@ -190,43 +190,6 @@ var _ = Describe("AppStore (Download)", func() { }) }) - When("primary endpoint returns FailureType 5002", func() { - BeforeEach(func() { - mockMachine.EXPECT(). - MacAddress(). - Return("", nil) - - gomock.InOrder( - mockDownloadClient.EXPECT(). - Send(gomock.Any()). - Do(func(req http.Request) { - Expect(req.URL).To(ContainSubstring(PrivateAppStoreAPIPathDownload)) - }). - Return(http.Result[downloadResult]{ - Data: downloadResult{ - FailureType: FailureTypeLicenseAlreadyExists, - }, - }, nil), - mockDownloadClient.EXPECT(). - Send(gomock.Any()). - Do(func(req http.Request) { - Expect(req.URL).To(ContainSubstring(PrivateDownloadDispatchAPIDomain)) - }). - Return(http.Result[downloadResult]{ - Data: downloadResult{ - FailureType: "secondary-failure", - }, - }, nil), - ) - }) - - It("falls back to the redownload endpoint and surfaces its error", func() { - _, err := as.Download(DownloadInput{}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("secondary-failure")) - }) - }) - When("store API returns error", func() { BeforeEach(func() { mockMachine.EXPECT(). diff --git a/pkg/appstore/appstore_get_version_metadata.go b/pkg/appstore/appstore_get_version_metadata.go index 64ecabc8..efa182be 100644 --- a/pkg/appstore/appstore_get_version_metadata.go +++ b/pkg/appstore/appstore_get_version_metadata.go @@ -35,15 +35,6 @@ func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersion return GetVersionMetadataOutput{}, fmt.Errorf("failed to send http request: %w", err) } - if res.Data.FailureType == FailureTypeLicenseAlreadyExists { - req = t.redownloadRequest(input.Account, input.App, guid, input.VersionID) - - res, err = t.downloadClient.Send(req) - if err != nil { - return GetVersionMetadataOutput{}, fmt.Errorf("failed to send http request: %w", err) - } - } - if res.Data.FailureType == FailureTypePasswordTokenExpired || res.Data.FailureType == FailureTypeSignInRequired { return GetVersionMetadataOutput{}, ErrPasswordTokenExpired } @@ -61,10 +52,6 @@ func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersion } if len(res.Data.Items) == 0 { - if res.Data.CustomerMessage != "" { - return GetVersionMetadataOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res) - } - return GetVersionMetadataOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res) } diff --git a/pkg/appstore/appstore_get_version_metadata_test.go b/pkg/appstore/appstore_get_version_metadata_test.go index 6e85b587..8c0a72a8 100644 --- a/pkg/appstore/appstore_get_version_metadata_test.go +++ b/pkg/appstore/appstore_get_version_metadata_test.go @@ -316,43 +316,6 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() { }) }) - When("primary endpoint returns FailureType 5002", func() { - BeforeEach(func() { - mockMachine.EXPECT(). - MacAddress(). - Return("00:11:22:33:44:55", nil) - - gomock.InOrder( - mockDownloadClient.EXPECT(). - Send(gomock.Any()). - Do(func(req http.Request) { - Expect(req.URL).To(ContainSubstring(PrivateAppStoreAPIPathDownload)) - }). - Return(http.Result[downloadResult]{ - Data: downloadResult{ - FailureType: FailureTypeLicenseAlreadyExists, - }, - }, nil), - mockDownloadClient.EXPECT(). - Send(gomock.Any()). - Do(func(req http.Request) { - Expect(req.URL).To(ContainSubstring(PrivateDownloadDispatchAPIDomain)) - }). - Return(http.Result[downloadResult]{ - Data: downloadResult{ - FailureType: "secondary-failure", - }, - }, nil), - ) - }) - - It("falls back to the redownload endpoint and surfaces its error", func() { - _, err := as.GetVersionMetadata(GetVersionMetadataInput{}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("secondary-failure")) - }) - }) - When("store API returns error", func() { BeforeEach(func() { mockMachine.EXPECT(). diff --git a/pkg/appstore/appstore_list_versions.go b/pkg/appstore/appstore_list_versions.go index 1359e69d..dc3c215d 100644 --- a/pkg/appstore/appstore_list_versions.go +++ b/pkg/appstore/appstore_list_versions.go @@ -33,15 +33,6 @@ func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, er return ListVersionsOutput{}, fmt.Errorf("failed to send http request: %w", err) } - if res.Data.FailureType == FailureTypeLicenseAlreadyExists { - req = t.redownloadRequest(input.Account, input.App, guid, "") - - res, err = t.downloadClient.Send(req) - if err != nil { - return ListVersionsOutput{}, fmt.Errorf("failed to send http request: %w", err) - } - } - if res.Data.FailureType == FailureTypePasswordTokenExpired || res.Data.FailureType == FailureTypeSignInRequired { return ListVersionsOutput{}, ErrPasswordTokenExpired } @@ -59,10 +50,6 @@ func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, er } if len(res.Data.Items) == 0 { - if res.Data.CustomerMessage != "" { - return ListVersionsOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res) - } - return ListVersionsOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res) } diff --git a/pkg/appstore/appstore_list_versions_test.go b/pkg/appstore/appstore_list_versions_test.go index 64d0a3b6..7b4fb67a 100644 --- a/pkg/appstore/appstore_list_versions_test.go +++ b/pkg/appstore/appstore_list_versions_test.go @@ -277,57 +277,6 @@ var _ = Describe("AppStore (ListVersions)", func() { }) }) - When("primary endpoint returns FailureType 5002", func() { - const ( - testVersion1 = "12345678" - testVersion2 = "87654321" - testLatest = "87654321" - ) - - BeforeEach(func() { - mockMachine.EXPECT(). - MacAddress(). - Return("00:00:00:00:00:00", nil) - - gomock.InOrder( - mockDownloadClient.EXPECT(). - Send(gomock.Any()). - Do(func(req http.Request) { - Expect(req.URL).To(ContainSubstring(PrivateAppStoreAPIPathDownload)) - }). - Return(http.Result[downloadResult]{ - Data: downloadResult{ - FailureType: FailureTypeLicenseAlreadyExists, - }, - }, nil), - mockDownloadClient.EXPECT(). - Send(gomock.Any()). - Do(func(req http.Request) { - Expect(req.URL).To(ContainSubstring(PrivateDownloadDispatchAPIDomain)) - }). - Return(http.Result[downloadResult]{ - Data: downloadResult{ - Items: []downloadItemResult{ - { - Metadata: map[string]interface{}{ - "softwareVersionExternalIdentifiers": []interface{}{testVersion1, testVersion2}, - "softwareVersionExternalIdentifier": testLatest, - }, - }, - }, - }, - }, nil), - ) - }) - - It("falls back to the redownload endpoint and returns versions", func() { - out, err := as.ListVersions(ListVersionsInput{}) - Expect(err).ToNot(HaveOccurred()) - Expect(out.ExternalVersionIdentifiers).To(Equal([]string{testVersion1, testVersion2})) - Expect(out.LatestExternalVersionID).To(Equal(testLatest)) - }) - }) - When("successfully lists versions", func() { const ( testVersion1 = "12345678" diff --git a/pkg/appstore/constants.go b/pkg/appstore/constants.go index 0fa616b2..66201514 100644 --- a/pkg/appstore/constants.go +++ b/pkg/appstore/constants.go @@ -25,9 +25,6 @@ const ( PrivateAppStoreAPIPathPurchase = "/WebObjects/MZFinance.woa/wa/buyProduct" PrivateAppStoreAPIPathDownload = "/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct" - PrivateDownloadDispatchAPIDomain = "downloaddispatch." + iTunesAPIDomain - PrivateDownloadDispatchAPIPath = "/r/redownload" - HTTPHeaderStoreFront = "X-Set-Apple-Store-Front" HTTPHeaderPod = "pod" From ef9d77769e72c06a0dfecbceacca23684adaddb8 Mon Sep 17 00:00:00 2001 From: koraytutuncu <74398122+koraytutuncu@users.noreply.github.com> Date: Mon, 11 May 2026 13:15:44 +0300 Subject: [PATCH 4/9] fix(cmd): use typed interactiveKey for context lookups cmd/root.go writes the interactive flag using a typed contextKey, but cmd/auth.go and cmd/download.go read it back with the raw string "interactive". The lookup misses, leaving a nil interface that crashes the bare type assertion in auth.go on every `auth login` invocation and silently disables the progress bar in `download` interactive mode. Switch both call sites to look the value up with the typed key and the comma-ok variant. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/auth.go | 2 +- cmd/download.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/auth.go b/cmd/auth.go index 34e1d4a2..0adf2e78 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -47,7 +47,7 @@ func loginCmd() *cobra.Command { Use: "login", Short: "Login to the App Store", RunE: func(cmd *cobra.Command, args []string) error { - interactive := cmd.Context().Value("interactive").(bool) + interactive, _ := cmd.Context().Value(interactiveKey).(bool) if password == "" && !interactive { return errors.New("password is required when not running in interactive mode; use the \"--password\" flag") diff --git a/cmd/download.go b/cmd/download.go index 0df1d366..30d7693c 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -81,7 +81,7 @@ func downloadCmd() *cobra.Command { Msg("purchase") } - interactive, _ := cmd.Context().Value("interactive").(bool) + interactive, _ := cmd.Context().Value(interactiveKey).(bool) var progress *progressbar.ProgressBar if interactive { progress = progressbar.NewOptions64(1, From 3764c8f00c5becbd3eb5560115c2874f0240326c Mon Sep 17 00:00:00 2001 From: koraytutuncu <74398122+koraytutuncu@users.noreply.github.com> Date: Mon, 11 May 2026 13:16:07 +0300 Subject: [PATCH 5/9] feat(appstore): expose redownload endpoint from bag Apple's bag at init.itunes.apple.com/bag.xml advertises a redownloadProduct key alongside the existing authenticateAccount. Parse it and surface it via BagOutput.DownloadEndpoint so callers can resolve the consumer redownload URL at runtime instead of hardcoding it. This commit only exposes the value; no caller consumes it yet. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/appstore/appstore_bag.go | 9 ++++++--- pkg/appstore/appstore_bag_test.go | 11 ++++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/pkg/appstore/appstore_bag.go b/pkg/appstore/appstore_bag.go index 29108a2f..b900f52c 100644 --- a/pkg/appstore/appstore_bag.go +++ b/pkg/appstore/appstore_bag.go @@ -11,7 +11,8 @@ import ( type BagInput struct{} type BagOutput struct { - AuthEndpoint string + AuthEndpoint string + DownloadEndpoint string } func (t *appstore) Bag(input BagInput) (BagOutput, error) { @@ -33,7 +34,8 @@ func (t *appstore) Bag(input BagInput) (BagOutput, error) { } return BagOutput{ - AuthEndpoint: res.Data.URLBag.AuthEndpoint, + AuthEndpoint: res.Data.URLBag.AuthEndpoint, + DownloadEndpoint: res.Data.URLBag.DownloadEndpoint, }, nil } @@ -42,7 +44,8 @@ type bagResult struct { } type urlBag struct { - AuthEndpoint string `plist:"authenticateAccount,omitempty"` + AuthEndpoint string `plist:"authenticateAccount,omitempty"` + DownloadEndpoint string `plist:"redownloadProduct,omitempty"` } func (*appstore) bagRequest(guid string) http.Request { diff --git a/pkg/appstore/appstore_bag_test.go b/pkg/appstore/appstore_bag_test.go index ae9f1f27..ba7c5992 100644 --- a/pkg/appstore/appstore_bag_test.go +++ b/pkg/appstore/appstore_bag_test.go @@ -85,8 +85,11 @@ var _ = Describe("AppStore (Bag)", func() { }) }) - When("request is successful with authenticateAccount in urlBag", func() { - const testAuthEndpoint = "https://example.com" + When("request is successful with endpoints in urlBag", func() { + const ( + testAuthEndpoint = "https://example.com" + testDownloadEndpoint = "https://downloaddispatch.example.com/r/redownload" + ) BeforeEach(func() { mockMachine.EXPECT(). @@ -105,7 +108,8 @@ var _ = Describe("AppStore (Bag)", func() { StatusCode: gohttp.StatusOK, Data: bagResult{ URLBag: urlBag{ - AuthEndpoint: testAuthEndpoint, + AuthEndpoint: testAuthEndpoint, + DownloadEndpoint: testDownloadEndpoint, }, }, }, nil) @@ -115,6 +119,7 @@ var _ = Describe("AppStore (Bag)", func() { out, err := as.Bag(BagInput{}) Expect(err).ToNot(HaveOccurred()) Expect(out.AuthEndpoint).To(Equal(testAuthEndpoint)) + Expect(out.DownloadEndpoint).To(Equal(testDownloadEndpoint)) }) }) From 031e44828f563b0387f11352859a12945beef96d Mon Sep 17 00:00:00 2001 From: koraytutuncu <74398122+koraytutuncu@users.noreply.github.com> Date: Mon, 11 May 2026 13:16:34 +0300 Subject: [PATCH 6/9] fix(appstore): resolve download endpoint from bag (#464) The volumeStoreDownloadProduct endpoint is Apple's VPP/ABM path. Consumer Apple IDs hit FailureType 5002 ("license already exists") on it for apps the account already owns - Microsoft Teams, Office, Facebook, Spotify and others - breaking ipatool's download for those apps. Apple's bag advertises a separate redownloadProduct endpoint at downloaddispatch.itunes.apple.com/r/redownload for exactly this case. Switch Download to consume the URL via DownloadInput.Endpoint, threaded from cmd/download.go's bag fetch which is now promoted out of the token-expired retry branch to the top of the retry function. Drop the 5002 -> ErrPasswordTokenExpired workaround that only existed because of the wrong endpoint. Switch the version-pinning payload key from externalVersionId to appExtVrsId which is what the redownload endpoint expects. Fixes the download half of #464. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/download.go | 14 +++++++------- pkg/appstore/appstore_download.go | 17 ++++++----------- pkg/appstore/appstore_download_test.go | 17 ++++++----------- 3 files changed, 19 insertions(+), 29 deletions(-) diff --git a/cmd/download.go b/cmd/download.go index 30d7693c..06151d10 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -35,6 +35,11 @@ func downloadCmd() *cobra.Command { purchased := false return retry.Do(func() error { + bag, err := dependencies.AppStore.Bag(appstore.BagInput{}) + if err != nil { + return fmt.Errorf("failed to get bag: %w", err) + } + infoResult, err := dependencies.AppStore.AccountInfo() if err != nil { return err @@ -43,15 +48,10 @@ func downloadCmd() *cobra.Command { acc = infoResult.Account if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) { - bagOutput, err := dependencies.AppStore.Bag(appstore.BagInput{}) - if err != nil { - return fmt.Errorf("failed to get bag: %w", err) - } - loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{ Email: acc.Email, Password: acc.Password, - Endpoint: bagOutput.AuthEndpoint, + Endpoint: bag.AuthEndpoint, }) if err != nil { return err @@ -101,7 +101,7 @@ func downloadCmd() *cobra.Command { } out, err := dependencies.AppStore.Download(appstore.DownloadInput{ - Account: acc, App: app, OutputPath: outputPath, Progress: progress, ExternalVersionID: externalVersionID}) + Account: acc, App: app, OutputPath: outputPath, Progress: progress, ExternalVersionID: externalVersionID, Endpoint: bag.DownloadEndpoint}) if err != nil { return err } diff --git a/pkg/appstore/appstore_download.go b/pkg/appstore/appstore_download.go index 4e714dab..23691eb1 100644 --- a/pkg/appstore/appstore_download.go +++ b/pkg/appstore/appstore_download.go @@ -24,6 +24,7 @@ type DownloadInput struct { OutputPath string Progress *progressbar.ProgressBar ExternalVersionID string + Endpoint string } type DownloadOutput struct { @@ -39,7 +40,7 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") - req := t.downloadRequest(input.Account, input.App, guid, input.ExternalVersionID) + req := t.downloadRequest(input.Endpoint, input.Account, input.App, guid, input.ExternalVersionID) res, err := t.downloadClient.Send(req) if err != nil { @@ -48,8 +49,7 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { if res.Data.FailureType == FailureTypePasswordTokenExpired || res.Data.FailureType == FailureTypeSignInRequired || - res.Data.FailureType == FailureTypeDeviceVerificationFailed || - res.Data.FailureType == FailureTypeLicenseAlreadyExists { + res.Data.FailureType == FailureTypeDeviceVerificationFailed { return DownloadOutput{}, ErrPasswordTokenExpired } @@ -172,7 +172,7 @@ func (t *appstore) downloadFile(src, dst string, progress *progressbar.ProgressB return nil } -func (*appstore) downloadRequest(acc Account, app App, guid string, externalVersionID string) http.Request { +func (*appstore) downloadRequest(endpoint string, acc Account, app App, guid string, externalVersionID string) http.Request { payload := map[string]interface{}{ "creditDisplay": "", "guid": guid, @@ -180,16 +180,11 @@ func (*appstore) downloadRequest(acc Account, app App, guid string, externalVers } if externalVersionID != "" { - payload["externalVersionId"] = externalVersionID - } - - podPrefix := "" - if acc.Pod != "" { - podPrefix = "p" + acc.Pod + "-" + payload["appExtVrsId"] = externalVersionID } return http.Request{ - URL: fmt.Sprintf("https://%s%s%s?guid=%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathDownload, guid), + URL: fmt.Sprintf("%s?guid=%s", endpoint, guid), Method: http.MethodPOST, ResponseFormat: http.ResponseFormatXML, Headers: map[string]string{ diff --git a/pkg/appstore/appstore_download_test.go b/pkg/appstore/appstore_download_test.go index f5fb8f90..f33f8c68 100644 --- a/pkg/appstore/appstore_download_test.go +++ b/pkg/appstore/appstore_download_test.go @@ -97,10 +97,10 @@ var _ = Describe("AppStore (Download)", func() { }) }) - When("request uses a custom pod", func() { + When("request is sent", func() { const ( - testPod = "42" - testGUID = "001122334455" + testEndpoint = "https://downloaddispatch.example.com/r/redownload" + testGUID = "001122334455" ) BeforeEach(func() { @@ -111,18 +111,13 @@ var _ = Describe("AppStore (Download)", func() { mockDownloadClient.EXPECT(). Send(gomock.Any()). Do(func(req http.Request) { - expectedURL := "https://p" + testPod + "-" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathDownload + "?guid=" + testGUID - Expect(req.URL).To(Equal(expectedURL)) + Expect(req.URL).To(Equal(testEndpoint + "?guid=" + testGUID)) }). Return(http.Result[downloadResult]{}, errors.New("")) }) - It("sends the download request to the pod-specific host", func() { - _, err := as.Download(DownloadInput{ - Account: Account{ - Pod: testPod, - }, - }) + It("sends the download request to the endpoint provided by the caller", func() { + _, err := as.Download(DownloadInput{Endpoint: testEndpoint}) Expect(err).To(HaveOccurred()) }) }) From dbf1ab01c40047ea3ee5b2776c465b5ab73a6461 Mon Sep 17 00:00:00 2001 From: koraytutuncu <74398122+koraytutuncu@users.noreply.github.com> Date: Mon, 11 May 2026 13:17:45 +0300 Subject: [PATCH 7/9] fix(appstore): resolve list-versions endpoint from bag (#464) ListVersions used the same volumeStoreDownloadProduct path and hit the same 5002 wall as Download for the affected apps. Switch it to the bag-resolved redownload URL by adding ListVersionsInput.Endpoint and threading it from cmd/list_versions.go's bag fetch, promoted out of the token-expired branch the same way the download command was. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/list_versions.go | 14 +++++++------- pkg/appstore/appstore_list_versions.go | 16 ++++++---------- pkg/appstore/appstore_list_versions_test.go | 17 ++++++----------- 3 files changed, 19 insertions(+), 28 deletions(-) diff --git a/cmd/list_versions.go b/cmd/list_versions.go index a583703c..dc195b26 100644 --- a/cmd/list_versions.go +++ b/cmd/list_versions.go @@ -29,6 +29,11 @@ func ListVersionsCmd() *cobra.Command { var acc appstore.Account return retry.Do(func() error { + bag, err := dependencies.AppStore.Bag(appstore.BagInput{}) + if err != nil { + return fmt.Errorf("failed to get bag: %w", err) + } + infoResult, err := dependencies.AppStore.AccountInfo() if err != nil { return err @@ -37,15 +42,10 @@ func ListVersionsCmd() *cobra.Command { acc = infoResult.Account if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) { - bagOutput, err := dependencies.AppStore.Bag(appstore.BagInput{}) - if err != nil { - return fmt.Errorf("failed to get bag: %w", err) - } - loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{ Email: acc.Email, Password: acc.Password, - Endpoint: bagOutput.AuthEndpoint, + Endpoint: bag.AuthEndpoint, }) if err != nil { return err @@ -64,7 +64,7 @@ func ListVersionsCmd() *cobra.Command { app = lookupResult.App } - out, err := dependencies.AppStore.ListVersions(appstore.ListVersionsInput{Account: acc, App: app}) + out, err := dependencies.AppStore.ListVersions(appstore.ListVersionsInput{Account: acc, App: app, Endpoint: bag.DownloadEndpoint}) if err != nil { return err } diff --git a/pkg/appstore/appstore_list_versions.go b/pkg/appstore/appstore_list_versions.go index dc3c215d..95fa298c 100644 --- a/pkg/appstore/appstore_list_versions.go +++ b/pkg/appstore/appstore_list_versions.go @@ -9,8 +9,9 @@ import ( ) type ListVersionsInput struct { - Account Account - App App + Account Account + App App + Endpoint string } type ListVersionsOutput struct { @@ -26,7 +27,7 @@ func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, er guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") - req := t.listVersionsRequest(input.Account, input.App, guid) + req := t.listVersionsRequest(input.Endpoint, input.Account, input.App, guid) res, err := t.downloadClient.Send(req) if err != nil { @@ -76,20 +77,15 @@ func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, er }, nil } -func (t *appstore) listVersionsRequest(acc Account, app App, guid string) http.Request { +func (t *appstore) listVersionsRequest(endpoint string, acc Account, app App, guid string) http.Request { payload := map[string]interface{}{ "creditDisplay": "", "guid": guid, "salableAdamId": app.ID, } - podPrefix := "" - if acc.Pod != "" { - podPrefix = "p" + acc.Pod + "-" - } - return http.Request{ - URL: fmt.Sprintf("https://%s%s%s?guid=%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathDownload, guid), + URL: fmt.Sprintf("%s?guid=%s", endpoint, guid), Method: http.MethodPOST, ResponseFormat: http.ResponseFormatXML, Headers: map[string]string{ diff --git a/pkg/appstore/appstore_list_versions_test.go b/pkg/appstore/appstore_list_versions_test.go index 7b4fb67a..1cd7a005 100644 --- a/pkg/appstore/appstore_list_versions_test.go +++ b/pkg/appstore/appstore_list_versions_test.go @@ -62,10 +62,10 @@ var _ = Describe("AppStore (ListVersions)", func() { }) }) - When("request uses a custom pod", func() { + When("request is sent", func() { const ( - testPod = "42" - testGUID = "001122334455" + testEndpoint = "https://downloaddispatch.example.com/r/redownload" + testGUID = "001122334455" ) BeforeEach(func() { @@ -76,18 +76,13 @@ var _ = Describe("AppStore (ListVersions)", func() { mockDownloadClient.EXPECT(). Send(gomock.Any()). Do(func(req http.Request) { - expectedURL := "https://p" + testPod + "-" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathDownload + "?guid=" + testGUID - Expect(req.URL).To(Equal(expectedURL)) + Expect(req.URL).To(Equal(testEndpoint + "?guid=" + testGUID)) }). Return(http.Result[downloadResult]{}, errors.New("")) }) - It("sends the request to the pod-specific host", func() { - _, err := as.ListVersions(ListVersionsInput{ - Account: Account{ - Pod: testPod, - }, - }) + It("sends the request to the endpoint provided by the caller", func() { + _, err := as.ListVersions(ListVersionsInput{Endpoint: testEndpoint}) Expect(err).To(HaveOccurred()) }) }) From 59c2f5a0aaa9c06550a9afbea66234e398fc1cb3 Mon Sep 17 00:00:00 2001 From: koraytutuncu <74398122+koraytutuncu@users.noreply.github.com> Date: Mon, 11 May 2026 13:18:10 +0300 Subject: [PATCH 8/9] fix(appstore): resolve get-version-metadata endpoint from bag (#464) GetVersionMetadata hit the same 5002 issue on volumeStoreDownloadProduct. Same fix as the other two: GetVersionMetadataInput.Endpoint threaded from cmd/get_version_metadata.go's bag fetch. Also switch the version-pinning payload key from externalVersionId to appExtVrsId, which is what the redownload endpoint expects. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/get_version_metadata.go | 13 ++++++------ pkg/appstore/appstore_get_version_metadata.go | 20 ++++++++----------- .../appstore_get_version_metadata_test.go | 17 ++++++---------- 3 files changed, 21 insertions(+), 29 deletions(-) diff --git a/cmd/get_version_metadata.go b/cmd/get_version_metadata.go index 3fcd3367..2b8a1668 100644 --- a/cmd/get_version_metadata.go +++ b/cmd/get_version_metadata.go @@ -30,6 +30,11 @@ func getVersionMetadataCmd() *cobra.Command { var acc appstore.Account return retry.Do(func() error { + bag, err := dependencies.AppStore.Bag(appstore.BagInput{}) + if err != nil { + return fmt.Errorf("failed to get bag: %w", err) + } + infoResult, err := dependencies.AppStore.AccountInfo() if err != nil { return err @@ -38,15 +43,10 @@ func getVersionMetadataCmd() *cobra.Command { acc = infoResult.Account if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) { - bagOutput, err := dependencies.AppStore.Bag(appstore.BagInput{}) - if err != nil { - return fmt.Errorf("failed to get bag: %w", err) - } - loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{ Email: acc.Email, Password: acc.Password, - Endpoint: bagOutput.AuthEndpoint, + Endpoint: bag.AuthEndpoint, }) if err != nil { return err @@ -69,6 +69,7 @@ func getVersionMetadataCmd() *cobra.Command { Account: acc, App: app, VersionID: externalVersionID, + Endpoint: bag.DownloadEndpoint, }) if err != nil { return err diff --git a/pkg/appstore/appstore_get_version_metadata.go b/pkg/appstore/appstore_get_version_metadata.go index efa182be..993c17ec 100644 --- a/pkg/appstore/appstore_get_version_metadata.go +++ b/pkg/appstore/appstore_get_version_metadata.go @@ -13,6 +13,7 @@ type GetVersionMetadataInput struct { Account Account App App VersionID string + Endpoint string } type GetVersionMetadataOutput struct { @@ -28,7 +29,7 @@ func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersion guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") - req := t.getVersionMetadataRequest(input.Account, input.App, guid, input.VersionID) + req := t.getVersionMetadataRequest(input.Endpoint, input.Account, input.App, guid, input.VersionID) res, err := t.downloadClient.Send(req) if err != nil { @@ -68,21 +69,16 @@ func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersion return GetVersionMetadataOutput(metadata), nil } -func (t *appstore) getVersionMetadataRequest(acc Account, app App, guid string, version string) http.Request { +func (t *appstore) getVersionMetadataRequest(endpoint string, acc Account, app App, guid string, version string) http.Request { payload := map[string]interface{}{ - "creditDisplay": "", - "guid": guid, - "salableAdamId": app.ID, - "externalVersionId": version, - } - - podPrefix := "" - if acc.Pod != "" { - podPrefix = "p" + acc.Pod + "-" + "creditDisplay": "", + "guid": guid, + "salableAdamId": app.ID, + "appExtVrsId": version, } return http.Request{ - URL: fmt.Sprintf("https://%s%s%s?guid=%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathDownload, guid), + URL: fmt.Sprintf("%s?guid=%s", endpoint, guid), Method: http.MethodPOST, ResponseFormat: http.ResponseFormatXML, Headers: map[string]string{ diff --git a/pkg/appstore/appstore_get_version_metadata_test.go b/pkg/appstore/appstore_get_version_metadata_test.go index 8c0a72a8..9ef552db 100644 --- a/pkg/appstore/appstore_get_version_metadata_test.go +++ b/pkg/appstore/appstore_get_version_metadata_test.go @@ -222,10 +222,10 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() { }) }) - When("request uses a custom pod", func() { + When("request is sent", func() { const ( - testPod = "42" - testGUID = "001122334455" + testEndpoint = "https://downloaddispatch.example.com/r/redownload" + testGUID = "001122334455" ) BeforeEach(func() { @@ -236,18 +236,13 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() { mockDownloadClient.EXPECT(). Send(gomock.Any()). Do(func(req http.Request) { - expectedURL := "https://p" + testPod + "-" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathDownload + "?guid=" + testGUID - Expect(req.URL).To(Equal(expectedURL)) + Expect(req.URL).To(Equal(testEndpoint + "?guid=" + testGUID)) }). Return(http.Result[downloadResult]{}, errors.New("request error")) }) - It("sends the request to the pod-specific host", func() { - _, err := as.GetVersionMetadata(GetVersionMetadataInput{ - Account: Account{ - Pod: testPod, - }, - }) + It("sends the request to the endpoint provided by the caller", func() { + _, err := as.GetVersionMetadata(GetVersionMetadataInput{Endpoint: testEndpoint}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to send http request")) }) From 032194273e86333322b56981f7adebbdd7b01ed3 Mon Sep 17 00:00:00 2001 From: koraytutuncu <74398122+koraytutuncu@users.noreply.github.com> Date: Mon, 11 May 2026 13:18:30 +0300 Subject: [PATCH 9/9] chore(appstore): drop unused volumeStoreDownloadProduct constant After Download, ListVersions and GetVersionMetadata moved to bag-resolved URLs, PrivateAppStoreAPIPathDownload has no remaining references in the package and would be dead code. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/appstore/constants.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/appstore/constants.go b/pkg/appstore/constants.go index 66201514..a231054e 100644 --- a/pkg/appstore/constants.go +++ b/pkg/appstore/constants.go @@ -23,7 +23,6 @@ const ( PrivateAppStoreAPIDomain = "buy." + iTunesAPIDomain PrivateAppStoreAPIPathPurchase = "/WebObjects/MZFinance.woa/wa/buyProduct" - PrivateAppStoreAPIPathDownload = "/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct" HTTPHeaderStoreFront = "X-Set-Apple-Store-Front" HTTPHeaderPod = "pod"