From f09c607aab9bb69c33b620f20424b7ac4be29492 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 14 Jul 2026 16:34:43 +0700 Subject: [PATCH] fix(extensions): surface swallowed signed-session verification in provider fallback Two holes let a provider's verification requirement vanish so the fallback chain failed outright instead of opening the challenge: - classifyDownloadErrorType did not recognize 'signed session expired' (the exact error ensureSignedSession returns), so an expired session failed generically. - Extensions that catch the needsVerification response internally (e.g. qobuz-web's availability search) report a plain 'not available', which the fallback loop skipped past silently. The runtime now remembers when a signed-session call inside the current script invocation required verification; the provider wrapper consumes that after checkAvailability/download and tags the failure as verification_required, regardless of how the script handled it. The source-extension path also classifies thrown errors now. --- go_backend/exports_download.go | 1 + go_backend/extension_fallback.go | 6 ++- go_backend/extension_provider_wrapper.go | 40 +++++++++++++++++++ go_backend/extension_runtime.go | 28 +++++++++++++ .../extension_runtime_supplement_test.go | 25 ++++++++++++ go_backend/extension_signed_session.go | 1 + 6 files changed, 100 insertions(+), 1 deletion(-) diff --git a/go_backend/exports_download.go b/go_backend/exports_download.go index 5b990bc8..5acf503f 100644 --- a/go_backend/exports_download.go +++ b/go_backend/exports_download.go @@ -457,6 +457,7 @@ func classifyDownloadErrorType(msg string) string { strings.Contains(lowerMsg, "needs verification") || strings.Contains(lowerMsg, "session is not authenticated") || strings.Contains(lowerMsg, "signed session is not authenticated") || + strings.Contains(lowerMsg, "signed session expired") || strings.Contains(lowerMsg, "unauthorized") || strings.Contains(lowerMsg, "precondition required") || messageHasHTTPStatusCode(lowerMsg, "401") || diff --git a/go_backend/extension_fallback.go b/go_backend/extension_fallback.go index 498e0744..bf210c3c 100644 --- a/go_backend/extension_fallback.go +++ b/go_backend/extension_fallback.go @@ -695,7 +695,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr) - if strings.EqualFold(lastErrType, "verification_required") { + sourceErrType := lastErrType + if sourceErrType == "" && lastErr != nil { + sourceErrType = classifyDownloadErrorType(lastErr.Error()) + } + if strings.EqualFold(sourceErrType, "verification_required") { GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source) return &DownloadResponse{ Success: false, diff --git a/go_backend/extension_provider_wrapper.go b/go_backend/extension_provider_wrapper.go index 5bc0ac47..321bcbed 100644 --- a/go_backend/extension_provider_wrapper.go +++ b/go_backend/extension_provider_wrapper.go @@ -457,6 +457,14 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a script: script, timeout: DefaultJSTimeout, itemID: itemID, + beforeRun: func() func() { + // Drop any stale flag so the post-run check below only sees + // verification requested by THIS call. + if p.extension.runtime != nil { + p.extension.runtime.consumeVerificationRequired() + } + return nil + }, }, func(perf *extensionCallPerf, result goja.Value) (*ExtAvailabilityResult, error) { if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { return &ExtAvailabilityResult{Available: false, Reason: "not implemented"}, nil @@ -465,6 +473,19 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a availability := parseExtensionAvailabilityValue(p.vm, result) perf.recordParse(time.Since(parseStartedAt)) perf.setItems(1) + // A signed-session call inside checkAvailability required + // verification. Extensions often swallow that and report a plain + // "not available", which would silently skip this provider's + // challenge; surface it as an error so the fallback loop pauses and + // opens the challenge instead. + if !availability.Available && p.extension.runtime != nil { + if p.extension.runtime.consumeVerificationRequired() != "" { + return nil, fmt.Errorf( + "VERIFY_REQUIRED: extension '%s' needs signed-session verification", + p.extension.ID, + ) + } + } return &availability, nil }) } @@ -533,6 +554,12 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID })() `, trackID, quality, outputPath) + if runtime != nil { + // Drop any stale flag (pooled runtimes survive across downloads) so + // the post-run check only sees verification from THIS call. + runtime.consumeVerificationRequired() + } + jsStartedAt := time.Now() result, err := RunWithTimeoutAndRecover(vm, script, ExtDownloadTimeout) perf.recordJS(time.Since(jsStartedAt)) @@ -573,6 +600,19 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID downloadResult.DecryptionKey, ) + // A signed-session call inside download() required verification but the + // script reported a generic failure; tag the result so the fallback loop + // pauses and opens this provider's challenge instead of skipping it. + if runtime != nil && !downloadResult.Success { + if runtime.consumeVerificationRequired() != "" && + !strings.EqualFold(downloadResult.ErrorType, "verification_required") { + downloadResult.ErrorType = "verification_required" + if downloadResult.ErrorMessage == "" { + downloadResult.ErrorMessage = "Verification required" + } + } + } + return &downloadResult, nil } diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index ea31cac4..05c996d0 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -139,6 +139,34 @@ type extensionRuntime struct { credentialsCache map[string]any credentialsLoaded bool storageFlushDelay time.Duration + + // Set when a signed-session call inside the current script invocation + // required verification. The provider wrapper consumes it after the + // script returns, so verification surfaces even when the extension + // script swallowed the needsVerification response (issue: fallback + // skipped provider B's challenge and failed outright). + verificationMu sync.Mutex + verificationRequiredURL string +} + +func (r *extensionRuntime) noteVerificationRequired(authURL string) { + r.verificationMu.Lock() + if authURL == "" { + authURL = "pending" + } + r.verificationRequiredURL = authURL + r.verificationMu.Unlock() +} + +// consumeVerificationRequired returns the noted auth URL (or "pending") and +// clears the flag; "" means no verification was requested since the last +// consume. +func (r *extensionRuntime) consumeVerificationRequired() string { + r.verificationMu.Lock() + url := r.verificationRequiredURL + r.verificationRequiredURL = "" + r.verificationMu.Unlock() + return url } type privateIPCacheEntry struct { diff --git a/go_backend/extension_runtime_supplement_test.go b/go_backend/extension_runtime_supplement_test.go index 33232621..39ac5498 100644 --- a/go_backend/extension_runtime_supplement_test.go +++ b/go_backend/extension_runtime_supplement_test.go @@ -1039,3 +1039,28 @@ func TestExtensionRuntimeUtilityAPIs(t *testing.T) { t.Fatalf("sanitize wrapper = %q", clean) } } + +func TestClassifySignedSessionExpiredAsVerification(t *testing.T) { + got := classifyDownloadErrorType("Failed to resolve Deezer download: signed session expired") + if got != "verification_required" { + t.Fatalf("expected verification_required, got %q", got) + } +} + +func TestVerificationRequiredNoteConsume(t *testing.T) { + r := &extensionRuntime{} + if got := r.consumeVerificationRequired(); got != "" { + t.Fatalf("expected empty before note, got %q", got) + } + r.noteVerificationRequired("") + if got := r.consumeVerificationRequired(); got != "pending" { + t.Fatalf("expected pending sentinel, got %q", got) + } + if got := r.consumeVerificationRequired(); got != "" { + t.Fatalf("expected cleared after consume, got %q", got) + } + r.noteVerificationRequired("https://x/auth") + if got := r.consumeVerificationRequired(); got != "https://x/auth" { + t.Fatalf("expected auth url, got %q", got) + } +} diff --git a/go_backend/extension_signed_session.go b/go_backend/extension_signed_session.go index 763c90b3..8c8ab9c4 100644 --- a/go_backend/extension_signed_session.go +++ b/go_backend/extension_signed_session.go @@ -370,6 +370,7 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value } func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value { + r.noteVerificationRequired(authURL) return r.vm.ToValue(map[string]any{ "ok": false, "needsVerification": true,