Skip to content

fix #464: fall back to the redownload endpoint when volumeStore returns 5002#483

Open
koraytutuncu wants to merge 1 commit into
majd:mainfrom
koraytutuncu:fix/464-download-endpoint-fallback
Open

fix #464: fall back to the redownload endpoint when volumeStore returns 5002#483
koraytutuncu wants to merge 1 commit into
majd:mainfrom
koraytutuncu:fix/464-download-endpoint-fallback

Conversation

@koraytutuncu
Copy link
Copy Markdown
Contributor

@koraytutuncu koraytutuncu commented May 29, 2026

fixes #464

Problem

For a consumer Apple ID, download, list-versions, and get-version-metadata break for any app the account already holds a license for (Microsoft Teams, Office, and others): the volumeStoreDownloadProduct endpoint returns failureType 5002. main additionally maps 5002 → ErrPasswordTokenExpired, which sends the CLI into a re-login loop before failing.

The earlier version of this PR replaced volumeStoreDownloadProduct with the bag's redownloadProduct. That fixes the licensed apps but breaks the opposite set: apps the account is not licensed for (e.g. YouTube, Instagram) are served by volumeStore but rejected by redownload with "<App> No Longer Available". Neither endpoint serves every app — so this is a fallback, not a swap.

What this does

volumeStore stays the primary endpoint (every app that works today keeps hitting the exact same endpoint — no regression). On failureType 5002 it falls back to the bag-resolved redownloadProduct. The redownload response then disambiguates two different causes of 5002:

  • redownload serves the app → it was genuinely licensed (Teams) → use it.
  • redownload also can't serve it ("No Longer Available") → the 5002 was a transient volumeStore hiccup → retry volumeStore.

This last point matters: volumeStore returns 5002 intermittently even for apps it can serve (≈1-in-8 calls, usually the first), so a naive "any 5002 → redownload" makes list-versions fail on an app that download succeeds on seconds later. The retry removes that inconsistency.

Other changes:

  • 5002 → ErrPasswordTokenExpired mapping removed (no more re-login loop).
  • Per-endpoint version-pin key: volumeStore reads externalVersionId, redownload reads appExtVrsId (the other key is silently ignored). A single shared helper now sends the request and threads the right key.
  • Empty results surface the endpoint's customerMessage (e.g. "No Longer Available") instead of a generic "invalid response".
  • download, list-versions, and get-version-metadata share one sendDownloadProduct + downloadProductItem (the three POST the same payload and decode the same shape) — net simplification.
  • volumeStore stays hardcoded: the bag advertises a volumeStoreDownloadProduct URL too, but it points at the enterprise/VPP host (downloaddispatch…/ent/download) which returns HTTP 500 for the client request shape. Only redownloadProduct is resolved from the bag.

Why not resolve volumeStore from the bag, and why retry instead of switch

The transcript below was produced by a live run against a real consumer Apple ID (account redacted). It shows the bag's volumeStore URL is unusable (HTTP 500), that 5002 is deterministic for a licensed app (Teams, 12/12) but intermittent for a servable one (YouTube), and that the version-pin key is endpoint-specific.

Verification

go test ./... && go vet ./... && gofmt -l are clean; the fallback is covered by unit specs across download / list-versions / get-version-metadata (including the tvOS key translation and the transient-5002 recovery). The transcript below also exercises every command end-to-end against a live account.

Automated checks

$ gofmt -l pkg cmd                 # empty = formatted
$ go vet ./pkg/... ./cmd/...
OK
$ go test ./pkg/... ./cmd/...
ok  	github.com/majd/ipatool/v2/pkg/appstore
ok  	github.com/majd/ipatool/v2/pkg/http
ok  	github.com/majd/ipatool/v2/pkg/keychain
ok  	github.com/majd/ipatool/v2/pkg/log
ok  	github.com/majd/ipatool/v2/pkg/util
ok  	github.com/majd/ipatool/v2/pkg/util/machine
ok  	github.com/majd/ipatool/v2/pkg/util/operatingsystem

Live end-to-end (real consumer Apple ID, account redacted)

#################### BACKGROUND (endpoint mechanics) ####################

## Endpoints advertised by the bag (init.itunes.apple.com/bag.xml)
  authenticateAccount          : https://auth.itunes.apple.com/auth/v1/native/fast
  redownloadProduct  (FALLBACK): https://downloaddispatch.itunes.apple.com/r/redownload
  volumeStoreDownloadProduct   : https://downloaddispatch.itunes.apple.com/WebObjects/DownloadDispatch.woa/wa/ent/download
                                 → HTTP 500, items=0 (enterprise/VPP shape; NOT usable for client downloads, so volumeStore stays hardcoded)
  hardcoded volumeStore (USED) : https://[p<pod>-]buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct

## volumeStore response stability — 12 calls/app (why a single 5002 must not switch endpoints)
  (sequence: . = served, X = 5002)
  app                            served    5002      other      sequence       verdict
  com.microsoft.skype.teams      0/12      12/12     0/12       XXXXXXXXXXXX   always 5002 → genuinely licensed → redownload
  com.google.ios.youtube         11/12     1/12      0/12       .X..........   intermittent 5002 → retry volumeStore
  com.spotify.client             12/12     0/12      0/12       ............   served by volumeStore
  note: Teams is deterministically 5002 (the #464 bug). volumeStore also returns 5002
        *intermittently* for apps it can otherwise serve (observed ~1-in-8 in repeated
        runs); that is why a 5002 triggers a redownload probe, and only a redownload that
        also cannot serve the app makes us retry volumeStore.

## Version-pin key is endpoint-specific (each endpoint reads only its own key)
  volumeStore (client ): externalVersionId  pins to 1862841 ✓   appExtVrsId        ignored, returns latest ✓
  redownload  (teams  ): appExtVrsId        pins to 823233975 ✓   externalVersionId  ignored, returns latest ✓


#################### FOREGROUND (CLI commands, real methods) ####################

## list-versions  (path each app takes shown in brackets)
  com.microsoft.skype.teams                ✓ 397 versions, latest=886178323  [volumeStore 5002 → redownload]
  com.google.ios.youtube         run 1/3   ✓ 596 versions, latest=885989940  [volumeStore (intermittent 5002, retried)]
  com.google.ios.youtube         run 2/3   ✓ 596 versions, latest=885989940  [volumeStore (intermittent 5002, retried)]
  com.google.ios.youtube         run 3/3   ✓ 596 versions, latest=885989940  [volumeStore (intermittent 5002, retried)]
  com.spotify.client                       ✓ 527 versions, latest=885637675  [volumeStore]

## get-version-metadata  (pins the latest version; reads the real IPA Info.plist)
  com.microsoft.skype.teams       ✓ version=8.9.2     released=2026-05-27  [volumeStore 5002 → redownload]
  com.google.ios.youtube          ✓ version=21.21.3   released=2026-05-22  [volumeStore (intermittent 5002, retried)]
  com.spotify.client              ✓ version=9.1.48    released=2026-05-11  [volumeStore]

## download  (the headline #464 path: licensed app via fallback → valid signed IPA)
  com.microsoft.skype.teams       ✓ 357.8 MB  Payload/TeamSpaceApp.app  sinf=true  iTunesMetadata=true  [volumeStore 5002 → redownload]

Single-environment caveat (same as the original disclosure): Apple's responses on these endpoints are undocumented and vary by account state, storefront, and family-sharing posture. Independent confirmation on other accounts/apps would help validate generality.


Disclosure: implemented and verified end-to-end with Claude Code against a live test account in one environment.


Summary by cubic

Fixes #464 by adding a safe fallback to the bag’s redownloadProduct when volumeStoreDownloadProduct returns failureType 5002. Restores downloads, list-versions, and version metadata for licensed apps without breaking non-licensed apps.

  • Bug Fixes

    • Keep volumeStore primary; on 5002, try redownloadProduct. If redownloadProduct also can’t serve (empty failureType + “No Longer Available”), retry volumeStore to handle transient 5002s.
    • Remove 5002 → ErrPasswordTokenExpired mapping to stop the re-login loop.
    • Surface endpoint customerMessage on empty results.
  • Refactors

    • Add shared sendDownloadProduct and downloadProductItem used by download, list-versions, and get-version-metadata in pkg/appstore.
    • Use endpoint-specific version keys: externalVersionId for volumeStore, appExtVrsId for redownloadProduct.
    • Bag now returns the redownloadProduct URL; cmd/* fetch the bag and pass RedownloadEndpoint through to pkg/appstore.
    • Keep the volumeStore URL hardcoded; the bag’s volumeStoreDownloadProduct points to VPP and returns HTTP 500 for client requests.

Written for commit e8ca429. Summary will update on new commits.

Review in cubic

volumeStore stays the primary download endpoint, so every app that works today keeps hitting the same endpoint. On failureType 5002 it falls back to the bag-resolved redownloadProduct. The redownload response disambiguates a genuine license (redownload serves the app) from a transient volumeStore 5002 (redownload cannot serve it -> retry volumeStore) -- the latter previously made list-versions fail on apps that download succeeded on.

- per-endpoint version-pin key: volumeStore uses externalVersionId, redownload uses appExtVrsId
- remove the 5002 -> ErrPasswordTokenExpired mapping that caused a re-login loop
- surface the endpoint customerMessage on empty results instead of a generic 'invalid response'
- download, list-versions and get-version-metadata share sendDownloadProduct + downloadProductItem
- volumeStore stays hardcoded: the bag's volumeStoreDownloadProduct URL is the enterprise/VPP host and returns HTTP 500 for the client request shape

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cannot list versions/download certain apps (Microsoft Teams)

1 participant