From fc4fa1d7d0e1452beca209713dd070fa2d0f3fad Mon Sep 17 00:00:00 2001 From: IronTony Date: Sat, 29 Aug 2026 14:29:46 +0200 Subject: [PATCH 1/4] fix(ios): validate signingUrl before presenting captive signing DSMEnvelopesManager.presentCaptiveSigning validates nothing and presents unconditionally, so a blank or non-https URL rendered an empty signing controller whose completion never fired, leaving the JS promise unsettled. CaptiveSigningUrlRecord defaults signingUrl to "", so an omitted field reached the SDK without a malformed URL being involved at all. The guard sits after the isInitialized check and before the stateQueue.sync that claims pendingCompletion, so a rejected URL never occupies the slot and a later valid call is not refused as "already in progress". Same exception type and message as the Android guard, so both platforms reject identically. Closes #5 --- ios/DocuSignManager.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/ios/DocuSignManager.swift b/ios/DocuSignManager.swift index 7d00dc0..206b245 100644 --- a/ios/DocuSignManager.swift +++ b/ios/DocuSignManager.swift @@ -672,6 +672,19 @@ internal final class DocuSignManager: NSObject { } } + /// Guards the URL handed to the SDK's URL overload. + /// + /// `DSMEnvelopesManager.presentCaptiveSigning(withPresenting:signingUrl:...)` validates nothing + /// and presents unconditionally, so a blank or non-https URL renders an empty signing controller + /// whose completion never fires and leaves the promise unsettled. `CaptiveSigningUrlRecord` + /// defaults `signingUrl` to "", so an omitted field reaches here as a blank string. + private static func isHttpsUrl(_ url: String) -> Bool { + guard let components = URLComponents(string: url) else { + return false + } + return components.scheme?.lowercased() == "https" && !(components.host ?? "").isEmpty + } + /// Presents captive signing from a pre-minted DocuSign recipient-view URL. /// /// The signing URL itself encodes recipient identity via a short-lived token, @@ -687,6 +700,12 @@ internal final class DocuSignManager: NSObject { throw NotInitializedException() } + // Ahead of the pendingCompletion claim on purpose: a rejected URL must not occupy the slot, or + // a later valid call would be refused as "already in progress". + guard Self.isHttpsUrl(signingUrl) else { + throw SigningFailedException("Signing URL must be a valid HTTPS URL") + } + var alreadyInFlight = false stateQueue.sync { if pendingCompletion != nil { From d3a71a12258c60cf6fb667651edc979a95d50beb Mon Sep 17 00:00:00 2001 From: IronTony Date: Sat, 29 Aug 2026 14:29:53 +0200 Subject: [PATCH 2/4] fix(ios): emit the error codes the README documents Expo derives a code from the exception class name when none is set, so NotInitializedException reached JS as ERR_NOT_INITIALIZED rather than the not_initialized the error table has always listed. The Android module got explicit codes in the previous change, which left the two platforms disagreeing on every code. All five exceptions now set their code. Both failure branches in the module hard-coded "signing_failed", which flattened presentation_failed and hid which stage failed. They now forward the failure's own code. promise.reject(error) is not the alternative here: it wraps anything that is not an Exception, so a raw SDK NSError would surface as ERR_UNEXPECTED. --- ios/DocuSignError.swift | 24 ++++++++++++++++++++++++ ios/DocuSignModule.swift | 18 ++++++++++++++---- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/ios/DocuSignError.swift b/ios/DocuSignError.swift index b1390fa..bf4ced3 100644 --- a/ios/DocuSignError.swift +++ b/ios/DocuSignError.swift @@ -1,30 +1,54 @@ import ExpoModulesCore +// Codes are given explicitly rather than inferred. Expo derives a code from the class name when +// none is set, which would surface NotInitializedException to JS as ERR_NOT_INITIALIZED, not the +// not_initialized documented in the README error table and emitted by the Android module. + internal class NotInitializedException: Exception { + override var code: String { + "not_initialized" + } + override var reason: String { "DocuSign SDK has not been initialized. Call initialize() first." } } internal class NotLoggedInException: Exception { + override var code: String { + "not_logged_in" + } + override var reason: String { "DocuSign SDK is not logged in. Call loginWithAccessToken() first." } } internal class PresentationException: GenericException { + override var code: String { + "presentation_failed" + } + override var reason: String { "Failed to present DocuSign signing UI: \(param)" } } internal class SigningFailedException: GenericException { + override var code: String { + "signing_failed" + } + override var reason: String { "DocuSign signing failed: \(param)" } } internal class LoginFailedException: GenericException { + override var code: String { + "login_failed" + } + override var reason: String { "DocuSign login failed: \(param)" } diff --git a/ios/DocuSignModule.swift b/ios/DocuSignModule.swift index d358891..1602970 100644 --- a/ios/DocuSignModule.swift +++ b/ios/DocuSignModule.swift @@ -109,12 +109,17 @@ public class DocuSignModule: Module { "errorMessage": outcome.errorMessage as Any ]) case .failure(let error): + // Forward the failure's own code. Hard-coding signing_failed flattened + // presentation_failed and hid which stage failed. promise.reject(error) is not the + // alternative here: it wraps anything that is not an Exception, so a raw SDK NSError + // would surface as ERR_UNEXPECTED. + let code = (error as? CodedError)?.code ?? "signing_failed" self.sendEvent("onSigningError", [ "envelopeId": params.envelopeId, - "errorCode": "signing_failed", + "errorCode": code, "errorMessage": error.localizedDescription ]) - promise.reject(SigningFailedException(error.localizedDescription)) + promise.reject(code, error.localizedDescription) } } } catch { @@ -138,12 +143,17 @@ public class DocuSignModule: Module { "errorMessage": outcome.errorMessage as Any ]) case .failure(let error): + // Forward the failure's own code. Hard-coding signing_failed flattened + // presentation_failed and hid which stage failed. promise.reject(error) is not the + // alternative here: it wraps anything that is not an Exception, so a raw SDK NSError + // would surface as ERR_UNEXPECTED. + let code = (error as? CodedError)?.code ?? "signing_failed" self.sendEvent("onSigningError", [ "envelopeId": params.envelopeId, - "errorCode": "signing_failed", + "errorCode": code, "errorMessage": error.localizedDescription ]) - promise.reject(SigningFailedException(error.localizedDescription)) + promise.reject(code, error.localizedDescription) } } } catch { From be6a9cc04f089ac662c325372d04e17ed14afebd Mon Sep 17 00:00:00 2001 From: IronTony Date: Sat, 29 Aug 2026 14:30:04 +0200 Subject: [PATCH 3/4] docs: record the iOS error codes and URL validation Add presentation_failed to the error table and state that both platforms emit the codes verbatim, since matching on the ERR_-prefixed variants was the only thing that worked on iOS before this release. --- CHANGELOG.md | 3 +++ README.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 617bb0e..d41b661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Breaking changes - **Android**: rejection codes now reflect the failure. `presentCaptiveSigning` and `presentCaptiveSigningWithUrl` previously rejected every error as `signing_failed`; they now surface `not_initialized`, `not_logged_in`, `login_failed` or `signing_failed`, matching the codes the error table has always documented. Callers matching on `error.code === 'signing_failed'` to detect a missing `initialize()` or `loginWithAccessToken()` need to match the specific code instead. +- **iOS**: rejection codes now match the documented table and the Android module. Expo derives a code from the exception class name when none is set, so `not_initialized` reached JS as `ERR_NOT_INITIALIZED`, `signing_failed` as `ERR_SIGNING_FAILED`, and so on for every code the README has always listed. Callers matching on the `ERR_`-prefixed variants need to match the documented code instead. +- **iOS**: `presentCaptiveSigning` and `presentCaptiveSigningWithUrl` forward the failure's own code rather than rejecting everything as `signing_failed`. A failure to find a presenting view controller now rejects and emits `presentation_failed`. The rejection message is the underlying error text on its own, where it previously carried a `DocuSign signing failed:` prefix. - **Android**: one `onSigningError` event per failure instead of two. The module emitted an event alongside the manager's own, which also flattened `recipient_signing_failed` into `signing_failed`. Listeners that deduplicated by hand can drop that workaround; listeners that counted events will see the count halve. ### New features @@ -14,6 +16,7 @@ ### Fixes +- **iOS**: reject a blank or non-`https` `signingUrl` before presenting. `DSMEnvelopesManager.presentCaptiveSigning` validates nothing and presents unconditionally, so a malformed URL rendered an empty signing controller whose completion never fired and left the promise unsettled. `signingUrl` defaults to `""` when JS omits it, so this was reachable without a malformed URL at all. Brings iOS to parity with the Android guard below. - **Android**: reject a blank or non-`https` `signingUrl` before launching. The SDK's URL overload validates nothing and calls `startActivity` unconditionally, so a malformed URL opened an empty signing activity and left the promise unsettled. - **Android**: `presentCaptiveSigning` now clears `currentEnvelopeId` when the launch itself throws, matching the URL path. diff --git a/README.md b/README.md index 04accd3..5c89334 100644 --- a/README.md +++ b/README.md @@ -803,6 +803,9 @@ The module rejects promises with coded exceptions you can inspect at the call si | `signing_failed` | SDK failed to present or complete signing | Check envelope ID, recipient info, SDK login state | | `not_initialized` | `initialize()` was not called first | Call `initialize()` before any other method | | `not_logged_in` | `loginWithAccessToken()` was not called first | Call `loginWithAccessToken()` before `presentCaptiveSigning()` | +| `presentation_failed` | No view controller available to present from (iOS) | Present from a mounted screen, not during a navigation transition | + +Both platforms emit these codes verbatim. Do not match on `ERR_`-prefixed variants. Example: From 00a2c9147463eaf03b9a039dc17944c51dc13976 Mon Sep 17 00:00:00 2001 From: IronTony Date: Sat, 29 Aug 2026 14:30:09 +0200 Subject: [PATCH 4/4] chore: ignore .claude/ Keeps local coding-agent state out of the repo and the npm tarball. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index bfb8171..acfa5f0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ .vscode/ jsconfig.json +# Coding agents +.claude/ + # Xcode # *.pbxuser