diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80792a1..ae42dde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: cache: npm - run: npm ci - run: npm run lint + # The error-handling guide includes these files verbatim, so a type error + # here means the published documentation no longer compiles. + - run: npm run typecheck:examples test: runs-on: ubuntu-latest @@ -51,4 +54,5 @@ jobs: node-version: 22 cache: npm - run: npm ci - - run: npm test + # With coverage, so the thresholds in jest.config.js are enforced. + - run: npm run test:coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index c2c93a5..e374896 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,18 +4,30 @@ ### 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. +- Every failure from `initialize`, `loginWithAccessToken`, `presentCaptiveSigning` and `presentCaptiveSigningWithUrl` rejects with a `DocuSignError` on both platforms. It carries `code` (what failed), `reason` (why, set only from verifiable facts), `native` and `http` (the raw SDK error and any DocuSign response), and `toAttributes()`. Branch on `code` and `reason`, never on message text, which was rewritten to say what failed and what to check. See [docs/ERROR_HANDLING.md](docs/ERROR_HANDLING.md). +- `presentCaptiveSigning` and `presentCaptiveSigningWithUrl` never resolve with `status: 'error'`. iOS used it for SDK errors reported after the signing UI was on screen, while Android rejected the same failures. Both platforms now reject, so a resolve always means completed or cancelled. `'error'` stays in `SigningStatus` so existing `switch` statements compile. +- Rejection codes are the documented lowercase codes on both platforms. iOS emitted `ERR_`-prefixed codes that Expo derived from exception class names, and Android rejected most failures as `signing_failed`. Two caller mistakes that hid inside `signing_failed` now have their own codes: `signing_in_progress` and `invalid_signing_url`. A missing presenter on iOS, or a missing foreground Activity on Android, rejects with `presentation_failed`, and an iOS initialization failure rejects with `initialize_failed`. +- `addSigningErrorListener` receives a `DocuSignError` instead of `{ errorCode, errorMessage }`, and now receives every failure from those four functions exactly once, caller mistakes included. It no longer wraps the native `onSigningError` event, which stays available on `DocuSignModule`. +- `useDocuSignSigning` types `error` as `DocuSignError | null`. +- **Android**: one native `onSigningError` event per failure instead of two. ### New features +- `DocuSignError.reason` classifies a failure as `usage`, `network`, `auth`, `configuration`, `recipient` or `unknown`, so an app can show a message that fits and skip retries that cannot succeed. A login failure is checked against `/oauth/userinfo` with the same token, which separates an expired token (`auth`) from a valid token DocuSign still refuses (`configuration`). +- `DocuSignError.toAttributes()` returns flat, primitive attributes ready for Amplitude, New Relic, Sentry or any other tool. +- Messages and details are redacted before they reach app code: JWTs, `Bearer` credentials, URL query strings and token-like URL path segments are removed. The redaction is pattern-based, so `toAttributes()`, which carries no message text, is the safest thing to forward to third-party tools. +- **Android**: `initialize` failures carry the SDK exception's details instead of a message alone, and the underlying error is the root of the exception's cause chain, where the transport failure that explains a timeout actually sits. +- In development, a caller mistake also prints one console warning naming the fix, so a catch that shows a generic toast cannot hide it. +- **Android**: the SDK's own error code, the HTTP status of an SDK REST failure, and DocuSign's error body from the recipient-view request are kept. The module previously forwarded only the exception message, and the `signingUrl` strategy discarded the error body entirely when it fell back to `fetch`. +- New [error handling guide](docs/ERROR_HANDLING.md) covering translated copy, retries, reporting to Amplitude, New Relic and Sentry, and reading the results in production. Its examples live in `examples/error-handling` and are type-checked in CI. + - **Android**: Add `presentCaptiveSigningWithUrl` support. The URL flow now has iOS/Android parity and does not require `loginWithAccessToken`. - **Android**: Add an opt-in `launchStrategy` on `presentCaptiveSigning`. `signingUrl` mints a recipient view and launches the SDK's URL overload, skipping the envelope download that runs on a size-derived read timeout floored at 15s and can leave the ceremony unopened on large envelopes. Falls back to `fetch` if the mint fails. Defaults to `fetch`, so upgrading changes nothing unless you opt in. ### Fixes +- **iOS**: the view controller to present from is looked up on the main thread. The lookup read `UIApplication.shared` on the background queue the JS call arrived on. +- **iOS**: a missing view controller settles the promise once. It previously completed the pending signing slot with a failure and also threw, rejecting the same call twice. - **iOS**: `endSigningSession` no longer calls `DSMManager` off the main thread. Expo dispatches a synchronous `AsyncFunction` body on a serial background queue, so `clearAllWebCookies()` and `logout()` were reached off-main on every call, including the one `useDocuSignSigning`'s `reset()` makes between flows. The guard now lives in `clearWebCookiesAsync`, the only method touching `DSMManager` and `WKWebsiteDataStore` directly, so it covers every caller. Thanks to @virajpsimformsolutions for finding and fixing this. - **iOS**: `reset()` no longer re-enters itself to reach the main thread. The hop sat below the block that cancels an in-flight signing promise, so the re-entrant pass ran that block twice and could cancel a session that claimed the slot in between. - **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. diff --git a/README.md b/README.md index 5c89334..9581fe6 100644 --- a/README.md +++ b/README.md @@ -252,27 +252,30 @@ async function signAgreement() { }); // Step 4: present the native signing UI - const result = await DocuSign.presentCaptiveSigning({ - envelopeId: session.envelopeId, - recipientUserName: session.userName, - recipientEmail: session.email, - recipientClientUserId: session.recipientClientUserId, - }); + try { + const result = await DocuSign.presentCaptiveSigning({ + envelopeId: session.envelopeId, + recipientUserName: session.userName, + recipientEmail: session.email, + recipientClientUserId: session.recipientClientUserId, + }); - switch (result.status) { - case 'completed': + if (result.status === 'completed') { console.log('Signed:', result.envelopeId); - break; - case 'cancelled': + } else { console.log('User cancelled signing'); - break; - case 'error': - console.error('Signing error:', result.errorMessage); - break; + } + } catch (error) { + if (error instanceof DocuSign.DocuSignError) { + // error.reason picks the message to show, error.toAttributes() goes to your logs. + console.error(error.code, error.reason, error.message); + } } } ``` +Every failure rejects with a `DocuSignError`. See [Error handling](#error-handling). + ## API reference ### `initialize(config: DocuSignConfig): Promise` @@ -291,7 +294,7 @@ type DocuSignConfig = { - `integratorKey`: your DocuSign Integrator Key (Client ID). Can be fetched from your backend at runtime to avoid shipping it in the app bundle. - `environment`: `'demo'` targets `demo.docusign.net` (DocuSign developer sandbox). `'production'` targets `docusign.net`. -**Throws:** rejects if the SDK cannot be initialized. +**Throws:** a `DocuSignError` with `initialize_failed` if the SDK cannot be configured. ### `loginWithAccessToken(params: DocuSignAuthParams): Promise` @@ -317,7 +320,10 @@ type DocuSignAuthParams = { - `email`: email address for the signer - `host`: DocuSign API host URL (e.g. `'https://demo.docusign.net/restapi'`) -**Throws:** rejects with `login_failed` if the token is invalid, expired, or rejected by DocuSign. +**Throws:** + +- `not_initialized` if `initialize` has not been called +- `login_failed` if DocuSign rejects the login. `reason` separates an expired or wrongly scoped token (`auth`) from a valid token DocuSign still refuses (`configuration`) and a lost connection (`network`). The package tells them apart by checking the same token against `/oauth/userinfo`. **Notes:** access tokens from DocuSign are typically valid for 1 hour. Do not cache them client-side. Fetch a fresh token for each signing session. @@ -335,10 +341,10 @@ type CaptiveSigningParams = { }; type SigningResult = { - status: 'completed' | 'cancelled' | 'error'; + status: 'completed' | 'cancelled' | 'error'; // 'error' is never returned since 2.0.0 envelopeId: string; errorCode?: string; - errorMessage?: string; + errorMessage?: string; // for 'cancelled', the SDK's exit reason when it gives one }; ``` @@ -349,11 +355,13 @@ type SigningResult = { - `recipientClientUserId`: the `clientUserId` of the embedded recipient, used by DocuSign to identify captive signers - `launchStrategy`: how the Android SDK opens the ceremony, see [Android launch strategies](#android-launch-strategies). Ignored on iOS. -**Throws:** +**Throws:** a `DocuSignError`. - `not_initialized` if `initialize` has not been called - `not_logged_in` if `loginWithAccessToken` has not been called -- `signing_failed` if the SDK fails to present the signing UI (e.g. invalid envelope, or a signing session already in progress) +- `signing_in_progress` if a ceremony is already open +- `presentation_failed` if there is no screen to present from +- `signing_failed` if the ceremony cannot open or ends with an error. `reason` tells a lost connection (`network`), an expired token (`auth`) or a recipient that does not match the envelope (`recipient`) apart from an SDK error the package cannot classify (`unknown`). **Returns:** resolves with a `SigningResult` once the user completes or cancels. `status === 'completed'` means the user finished the signing ceremony. `status === 'cancelled'` means the user explicitly cancelled or closed the signing UI. @@ -397,10 +405,13 @@ type CaptiveSigningUrlParams = { - `envelopeId`: the DocuSign envelope ID - `recipientId` (optional): identifier used for event correlation -**Throws:** +**Throws:** a `DocuSignError`. - `not_initialized` if `initialize` has not been called -- `signing_failed` if the URL is blank or not `https`, or if it is expired or rejected by DocuSign +- `invalid_signing_url` if the URL is blank or not `https` +- `signing_in_progress` if a ceremony is already open +- `presentation_failed` if there is no screen to present from +- `signing_failed` if the URL is expired or rejected by DocuSign, with `reason` as for `presentCaptiveSigning` **Returns:** same `SigningResult` shape as `presentCaptiveSigning`. @@ -450,8 +461,9 @@ const cancelSub = DocuSign.addSigningCancelledListener((event) => { console.log('Cancelled:', event.envelopeId, event.reason); }); -const errorSub = DocuSign.addSigningErrorListener((event) => { - console.error('Error:', event.errorCode, event.errorMessage); +const errorSub = DocuSign.addSigningErrorListener((error) => { + // Every DocuSignError, caller mistakes included, once each and before the call rejects. + analytics.track('docusign_failed', error.toAttributes()); }); // Later, clean up: @@ -460,6 +472,8 @@ cancelSub.remove(); errorSub.remove(); ``` +`addSigningErrorListener` covers `initialize`, `loginWithAccessToken`, `presentCaptiveSigning` and `presentCaptiveSigningWithUrl`. Register it once at startup to send every failure to your analytics or error reporting tool, as shown in the [error handling guide](docs/ERROR_HANDLING.md#sending-errors-to-your-tools). + In React hooks: ```ts @@ -512,7 +526,7 @@ function SigningScreen() { {state === 'signing' && Opening signing UI...} {state === 'completed' && Signed envelope {result?.envelopeId}} {state === 'cancelled' && Signing cancelled} - {state === 'error' && Error: {error?.message}} + {state === 'error' && error && {t(DOCUSIGN_ERROR_COPY_KEY[error.reason])}} ); } @@ -525,7 +539,7 @@ function SigningScreen() { - `{ type: 'session', ... }`: runs `loginWithAccessToken` + `presentCaptiveSigning` (iOS + Android) - `{ type: 'url', ... }`: runs `presentCaptiveSigningWithUrl` (iOS + Android, no SDK login) - Tracks SDK state in a finite state machine -- Subscribes to error events and surfaces them in the `error` field +- Stores the latest `DocuSignError` in the `error` field. `DOCUSIGN_ERROR_COPY_KEY` in the usage example comes from the [error handling guide](docs/ERROR_HANDLING.md#copyts) - Cleans up event listeners on unmount ### URL-flow example (iOS + Android) @@ -597,7 +611,7 @@ type UseDocuSignSigningOptions = { type UseDocuSignSigningReturn = { state: DocuSignSigningState; - error: Error | null; + error: DocuSignError | null; result: SigningResult | null; initialize: () => Promise; // manual init if autoInitialize=false startSigning: (session: SigningSession) => Promise; @@ -635,6 +649,7 @@ export type CaptiveSigningParams = { recipientClientUserId: string; }; +// 'error' is never returned since 2.0.0. It stays so existing switch statements compile. export type SigningStatus = 'completed' | 'cancelled' | 'error'; export type SigningResult = { @@ -653,13 +668,37 @@ export type SigningCancelledEvent = { reason?: string; }; -export type SigningErrorEvent = { +export class DocuSignError extends Error { + code: DocuSignErrorCode; + reason: DocuSignErrorReason; envelopeId?: string; - errorCode: string; - errorMessage: string; -}; + native?: DocuSignNativeErrorDetails; + http?: DocuSignHttpErrorDetails; + toAttributes(): DocuSignErrorAttributes; +} + +export type DocuSignErrorCode = + | 'not_initialized' + | 'not_logged_in' + | 'signing_in_progress' + | 'invalid_signing_url' + | 'presentation_failed' + | 'initialize_failed' + | 'login_failed' + | 'signing_failed' + | 'unexpected'; + +export type DocuSignErrorReason = + | 'usage' + | 'network' + | 'auth' + | 'configuration' + | 'recipient' + | 'unknown'; ``` +The full error model, including `native`, `http` and the attributes, is in the [error handling guide](docs/ERROR_HANDLING.md#the-error-model). + ## Authentication flow This package does NOT implement DocuSign JWT Grant authentication. JWT Grant must happen on your backend because it requires access to an RSA private key that should never ship on mobile devices. @@ -794,35 +833,39 @@ Between signings, you can also show a confirmation prompt ("sign another documen ## Error handling -The module rejects promises with coded exceptions you can inspect at the call site: - -| Error code | When | Mitigation | -| ------------------- | --------------------------------------------- | -------------------------------------------------------------- | -| `initialize_failed` | SDK failed to configure | Check integrator key and network connectivity | -| `login_failed` | Access token rejected, or Keychain misconfigured (iOS) | Fetch a fresh token from your backend; on iOS also verify `AppIdentifierPrefix` is set in `Info.plist` (see [Permissions](#permissions)) | -| `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 | +Every failure rejects with a `DocuSignError`, on both platforms, with the same fields: -Both platforms emit these codes verbatim. Do not match on `ERR_`-prefixed variants. +- `code` says what failed, such as `login_failed` or `signing_in_progress`. It is stable and safe to branch on. +- `reason` says why: `usage`, `network`, `auth`, `configuration`, `recipient` or `unknown`. The package sets it only from facts it can verify, so it never guesses. +- `message` is English text for developers. Never show it to users. +- `native` and `http` carry the raw SDK error and any DocuSign response behind the failure. +- `toAttributes()` flattens all of it into primitive values for Amplitude, New Relic, Sentry or any other tool. -Example: +The package shows no UI, ships no user-facing strings and logs nothing in production. Your app picks the copy from `reason` and decides where errors are recorded: ```ts try { await DocuSign.presentCaptiveSigning(params); } catch (error) { - if (error.code === 'not_logged_in') { - const session = await refreshSession(); - await DocuSign.loginWithAccessToken(session); - // retry - } else { - reportError(error); + if (!(error instanceof DocuSign.DocuSignError)) throw error; + showToast({ title: t(DOCUSIGN_ERROR_COPY_KEY[error.reason]) }); + if (error.reason === 'auth') { + await startOverWithFreshSession(); } } ``` +| `reason` | Who fixes it | Suggested copy | Retry | +| --- | --- | --- | --- | +| `usage` | the app developer | generic | no, it is a bug | +| `network` | nobody, the connection was lost | "Check your connection and try again." | yes | +| `auth` | backend token minting | "Your signing session expired." | after a fresh session | +| `configuration` | DocuSign admin, or `AppIdentifierPrefix` on iOS | "Signing isn't available right now." | no | +| `recipient` | backend envelope creation | "Please contact the sender." | no | +| `unknown` | investigate with `native` | generic | yes | + +The [error handling guide](docs/ERROR_HANDLING.md) covers the full error model, translated copy with i18next, a retry policy, sending errors to Amplitude, New Relic and Sentry, the dashboards and queries to read them in production, and worked examples for every reason. Its code samples are type-checked in CI. + ## Security considerations 1. **Never log access tokens.** If you use LogRocket, Sentry, Datadog, or any other session recording tool, add the signing session endpoint and any DocuSign token fields to your network scrub list. @@ -880,11 +923,11 @@ The SDK login state is in-memory and does not survive app restarts. Always call ### Access token expired mid-signing -DocuSign access tokens are valid for about 1 hour. If a token expires while the signing UI is open, the SDK emits an error event and the promise rejects. Catch the error, fetch a fresh session from your backend, and retry. +DocuSign access tokens are valid for about 1 hour. If a token expires while the signing UI is open, the promise rejects with a `DocuSignError`. Catch it, fetch a fresh session from your backend, and start over rather than retrying the same session. ### `login_failed` on iOS even with a valid token -If `loginWithAccessToken` rejects with `login_failed` (or the SDK logs "unauthorized") but the same token works on Android, the most likely cause is a missing `AppIdentifierPrefix` entry in your `Info.plist`. +If `loginWithAccessToken` rejects with `login_failed` and `reason: 'configuration'`, DocuSign accepted the token at `/oauth/userinfo` but the SDK still refused it. When the same token works on Android, the most likely cause is a missing `AppIdentifierPrefix` entry in your `Info.plist`. The DocuSign iOS SDK uses the Apple Keychain to store auth state. Without `AppIdentifierPrefix`, it cannot access the Keychain group and rejects the login at the SDK level, regardless of token validity. diff --git a/android/src/main/java/expo/modules/docusign/DocuSignError.kt b/android/src/main/java/expo/modules/docusign/DocuSignError.kt index ecf059e..89c7819 100644 --- a/android/src/main/java/expo/modules/docusign/DocuSignError.kt +++ b/android/src/main/java/expo/modules/docusign/DocuSignError.kt @@ -2,8 +2,9 @@ package expo.modules.docusign import expo.modules.kotlin.exception.CodedException -// Codes are given explicitly rather than inferred. CodedException derives a code from the class -// name when none is provided, which would surface NotInitializedException to JS as +// Caller mistakes only. Runtime failures travel as DocuSignFailure so their details survive the +// bridge. Codes are given explicitly rather than inferred: CodedException derives a code from the +// class name when none is provided, which would surface NotInitializedException to JS as // ERR_NOT_INITIALIZED, not the not_initialized documented in the README error table. class NotInitializedException : CodedException( @@ -18,15 +19,15 @@ class NotLoggedInException : CodedException( null ) -class LoginFailedException(message: String) : CodedException( - "login_failed", - "DocuSign login failed: $message", +class SigningInProgressException : CodedException( + "signing_in_progress", + "A signing session is already in progress. Wait for it to finish or call endSigningSession() first.", null ) -class SigningFailedException(message: String) : CodedException( - "signing_failed", - "DocuSign signing failed: $message", +class InvalidSigningUrlException : CodedException( + "invalid_signing_url", + "signingUrl must be a non-empty https URL.", null ) diff --git a/android/src/main/java/expo/modules/docusign/DocuSignFailure.kt b/android/src/main/java/expo/modules/docusign/DocuSignFailure.kt new file mode 100644 index 0000000..b4f3f77 --- /dev/null +++ b/android/src/main/java/expo/modules/docusign/DocuSignFailure.kt @@ -0,0 +1,145 @@ +package expo.modules.docusign + +import com.docusign.androidsdk.exceptions.DSException +import com.docusign.androidsdk.exceptions.DSRestException +import java.io.IOException +import org.json.JSONObject + +/** + * Facts about a runtime failure, sent to JS as flat keys. + * + * JS derives the failure's `reason` from these, so native code reports what it observed and never + * guesses. Every field is optional because each failure knows a different subset. + */ +internal data class FailureDetails( + val nativeDomain: String? = null, + val nativeCode: String? = null, + val nativeMessage: String? = null, + val underlyingDomain: String? = null, + val underlyingCode: String? = null, + val underlyingMessage: String? = null, + val httpStatus: Int? = null, + val docusignErrorCode: String? = null, + val docusignMessage: String? = null +) { + /** Records a lower-level error without overwriting one the SDK already reported. */ + fun withUnderlyingIfAbsent(error: Throwable): FailureDetails { + if (underlyingDomain != null) return this + val root = rootCause(error) ?: error + return copy( + underlyingDomain = root.javaClass.name, + underlyingCode = sdkErrorCode(root), + underlyingMessage = root.message + ) + } + + fun withHttp(error: DocuSignHttpException): FailureDetails = + copy( + httpStatus = error.status, + docusignErrorCode = error.docusignErrorCode, + docusignMessage = error.docusignMessage + ) + + fun toMap(): Map = + listOfNotNull>( + nativeDomain?.let { "nativeDomain" to it }, + nativeCode?.let { "nativeCode" to it }, + nativeMessage?.let { "nativeMessage" to it }, + underlyingDomain?.let { "underlyingDomain" to it }, + underlyingCode?.let { "underlyingCode" to it }, + underlyingMessage?.let { "underlyingMessage" to it }, + httpStatus?.let { "httpStatus" to it }, + docusignErrorCode?.let { "docusignErrorCode" to it }, + docusignMessage?.let { "docusignMessage" to it } + ).toMap() + + companion object { + /** + * Reads the SDK's own error code and message where it provides them. The exception message + * alone was all this module forwarded before, and it rarely says what failed. + */ + fun from(error: Throwable): FailureDetails { + val cause = rootCause(error) + return FailureDetails( + nativeDomain = error.javaClass.name, + nativeCode = sdkErrorCode(error), + nativeMessage = (error as? DSException)?.errorMsg?.takeIf { it.isNotBlank() } ?: error.message, + underlyingDomain = cause?.javaClass?.name, + underlyingCode = cause?.let { sdkErrorCode(it) }, + underlyingMessage = cause?.message, + httpStatus = (error as? DSRestException)?.responseCode?.takeIf { it > 0 } + ) + } + + private fun sdkErrorCode(error: Throwable): String? = + (error as? DSException)?.errorCode?.takeIf { it.isNotBlank() } + + private const val MAX_CAUSE_DEPTH = 8 + + /** + * The deepest cause, bounded and safe against cycles. Java wraps transport failures, so the + * SocketTimeoutException or UnknownHostException that explains a failure sits at the root of + * the chain, often more than one level below the exception the SDK hands over. iOS keeps the + * immediate underlying error instead, because the deepest error under an NSURLErrorDomain + * failure is a CFNetwork error that the reason rules do not classify as a network failure. + */ + private fun rootCause(error: Throwable): Throwable? { + val seen = mutableSetOf(error) + var current = error.cause ?: return null + repeat(MAX_CAUSE_DEPTH) { + seen.add(current) + val next = current.cause + if (next == null || next in seen) return current + current = next + } + return current + } + } +} + +/** + * A non-2xx response to a request this package makes itself. DocuSign's error body names the real + * problem, such as `UNKNOWN_ENVELOPE_RECIPIENT` for a recipient that does not match the envelope, + * so it is kept rather than reduced to the status line. + */ +internal class DocuSignHttpException( + val status: Int, + val docusignErrorCode: String?, + val docusignMessage: String? +) : IOException("DocuSign request failed with HTTP $status") { + companion object { + fun from(status: Int, body: String): DocuSignHttpException { + val json = runCatching { JSONObject(body) }.getOrNull() + return DocuSignHttpException( + status = status, + docusignErrorCode = json?.nonEmpty("errorCode") ?: json?.nonEmpty("error"), + docusignMessage = json?.nonEmpty("message") ?: json?.nonEmpty("error_description") + ) + } + + private fun JSONObject.nonEmpty(key: String): String? = + if (isNull(key)) null else optString(key).takeIf { it.isNotBlank() } + } +} + +/** + * A runtime failure, as opposed to a caller mistake. + * + * Caller mistakes are coded exceptions and reject. A rejection crosses the Expo bridge with only a + * code and a message, so runtime failures resolve with this payload instead and JS turns it into a + * thrown `DocuSignError` with every detail intact. + */ +internal class DocuSignFailure( + val code: String, + override val message: String, + val details: FailureDetails, + val envelopeId: String? = null +) : Exception(message) { + fun toPayload(fallbackEnvelopeId: String?): Map { + val payload = details.toMap().toMutableMap() + payload["errorCode"] = code + payload["errorMessage"] = message + (envelopeId ?: fallbackEnvelopeId)?.takeIf { it.isNotEmpty() }?.let { payload["envelopeId"] = it } + return payload + } +} diff --git a/android/src/main/java/expo/modules/docusign/DocuSignManager.kt b/android/src/main/java/expo/modules/docusign/DocuSignManager.kt index 320e8e8..b9f9a00 100644 --- a/android/src/main/java/expo/modules/docusign/DocuSignManager.kt +++ b/android/src/main/java/expo/modules/docusign/DocuSignManager.kt @@ -78,7 +78,15 @@ internal data class SigningOutcome( val envelopeId: String, val errorCode: String? = null, val errorMessage: String? = null -) +) { + fun toPayload(): Map = + listOfNotNull>( + "status" to status, + "envelopeId" to envelopeId, + errorCode?.let { "errorCode" to it }, + errorMessage?.let { "errorMessage" to it } + ).toMap() +} internal data class DocuSignAccountInfo( val accountId: String, @@ -100,7 +108,11 @@ internal object DocuSignManager { @Volatile private var session: DocuSignSession? = null private val pendingCompletion = AtomicReference<((Result) -> Unit)?>(null) - private enum class UserInfoProbe { OK, UNAUTHORIZED, NETWORK } + private sealed class UserInfoProbe { + /** `error` carries DocuSign's error body for a non-2xx status, and is null otherwise. */ + data class Response(val status: Int, val error: DocuSignHttpException?) : UserInfoProbe() + data class TransportFailed(val error: Throwable) : UserInfoProbe() + } fun setModule(module: DocuSignModule) { this.module = module @@ -167,34 +179,57 @@ internal object DocuSignManager { } override fun onError(exception: DSAuthenticationException) { - val sdkMsg = exception.message ?: "Unknown error" - classifyLoginFailure(accessToken, sdkMsg) { enrichedMsg -> - completion(Result.failure(LoginFailedException(enrichedMsg))) + classifyLoginFailure(accessToken, exception) { failure -> + completion(Result.failure(failure)) } } } ) } catch (e: Exception) { - completion(Result.failure(LoginFailedException(e.message ?: "Unknown error"))) + completion( + Result.failure( + DocuSignFailure( + code = "login_failed", + message = "DocuSign login could not start: ${e.message ?: e.javaClass.simpleName}", + details = FailureDetails.from(e) + ) + ) + ) } } + /** + * The SDK's login error rarely says why. A second call to `/oauth/userinfo` with the same token + * separates an expired or wrongly scoped token (401 or 403) from a valid token the SDK still + * refuses, which points at DocuSign admin configuration rather than the backend. + */ private fun classifyLoginFailure( accessToken: String, - sdkMsg: String, - completion: (String) -> Unit + sdkError: Throwable, + completion: (DocuSignFailure) -> Unit ) { probeUserInfoStatus(accessToken) { probe -> val diagnostic = "integratorKey=$integratorKey environment=${environment.value}" - val enriched = when (probe) { - UserInfoProbe.OK -> - "SDK rejected a valid token. Likely causes: Mobile SDK not enabled for integration key $integratorKey, or Android package name not whitelisted in DocuSign admin. Contact DocuSign support. (SDK: $sdkMsg) | $diagnostic" - UserInfoProbe.UNAUTHORIZED -> - "Access token rejected by DocuSign /oauth/userinfo. Re-mint via JWT Bearer Grant with scope=signature impersonation. (SDK: $sdkMsg) | $diagnostic" - UserInfoProbe.NETWORK -> - "$sdkMsg | $diagnostic" + val sdkDetails = FailureDetails.from(sdkError) + val (summary, details) = when (probe) { + is UserInfoProbe.Response -> { + val withStatus = probe.error?.let { sdkDetails.withHttp(it) } + ?: sdkDetails.copy(httpStatus = probe.status) + val summary = when (probe.status) { + in 200..299 -> + "DocuSign rejected a valid access token. The Mobile SDK may not be enabled for integration key $integratorKey, or the Android package name is not allowed in DocuSign admin." + 401, 403 -> + "DocuSign rejected the access token. Mint a new token with the signature and impersonation scopes." + else -> + "DocuSign login failed, and the userinfo check returned HTTP ${probe.status}." + } + summary to withStatus + } + is UserInfoProbe.TransportFailed -> + "DocuSign login failed, and the userinfo check could not reach DocuSign." to + sdkDetails.withUnderlyingIfAbsent(probe.error) } - completion(enriched) + completion(DocuSignFailure(code = "login_failed", message = "$summary ($diagnostic)", details = details)) } } @@ -213,17 +248,17 @@ internal object DocuSignManager { setRequestProperty("Authorization", "Bearer $accessToken") setRequestProperty("Accept", "application/json") } - when (val code = connection.responseCode) { - in 200..299 -> UserInfoProbe.OK - 401, 403 -> UserInfoProbe.UNAUTHORIZED - else -> { - android.util.Log.w("DocuSign", "userinfo probe HTTP $code") - UserInfoProbe.NETWORK - } + val status = connection.responseCode + if (status in 200..299) { + UserInfoProbe.Response(status, null) + } else { + // Only the error body is read. A successful response is the user's profile, which the + // failure has no use for. + val body = connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "" + UserInfoProbe.Response(status, DocuSignHttpException.from(status, body)) } } catch (e: Exception) { - android.util.Log.w("DocuSign", "userinfo probe error: ${e.message}") - UserInfoProbe.NETWORK + UserInfoProbe.TransportFailed(e) } finally { connection?.disconnect() } @@ -327,7 +362,7 @@ internal object DocuSignManager { } if (!pendingCompletion.compareAndSet(null, completion)) { - completion(Result.failure(SigningFailedException("A signing session is already in progress"))) + completion(Result.failure(SigningInProgressException())) return } currentEnvelopeId = envelopeId @@ -353,8 +388,11 @@ internal object DocuSignManager { /** * One listener for every launch path. Both entrypoints previously built their own copy, so a fix * to any callback had to be made twice and the copies could drift. + * + * `mintFailure` is set when the signing-URL strategy fell back to fetch, so a fetch failure still + * carries what the mint learned. */ - private fun captiveSigningListener() = object : DSCaptiveSigningListener { + private fun captiveSigningListener(mintFailure: Throwable? = null) = object : DSCaptiveSigningListener { override fun onStart(envelopeId: String) {} override fun onSuccess(envelopeId: String) { @@ -366,7 +404,9 @@ internal object DocuSignManager { } override fun onError(envelopeId: String?, exception: DSSigningException) { - handleSigningError(envelopeId, "signing_failed", exception.message ?: "Unknown error") + handleSigningError( + signingFailure(envelopeId, exception, mintFailure, "DocuSign signing failed") + ) } override fun onRecipientSigningSuccess(envelopeId: String, recipientId: String) {} @@ -377,13 +417,36 @@ internal object DocuSignManager { exception: DSSigningException ) { handleSigningError( - envelopeId, - "recipient_signing_failed", - exception.message ?: "Unknown error" + signingFailure(envelopeId, exception, mintFailure, "DocuSign reported a recipient signing error") ) } } + /** + * A mint rejected with DocuSign's own error code, such as a recipient that does not match the + * envelope, names the real problem where the fetch path's error usually does not, so it is folded + * into the failure the caller finally receives. + */ + private fun signingFailure( + envelopeId: String?, + error: Throwable, + mintFailure: Throwable?, + summary: String + ): DocuSignFailure { + val sdkDetails = FailureDetails.from(error) + val details = when (mintFailure) { + null -> sdkDetails + is DocuSignHttpException -> sdkDetails.withHttp(mintFailure) + else -> sdkDetails.withUnderlyingIfAbsent(mintFailure) + } + return DocuSignFailure( + code = "signing_failed", + message = "$summary: ${error.message ?: error.javaClass.simpleName}", + details = details, + envelopeId = envelopeId + ) + } + /** * Guards every URL this object hands to the SDK or sends credentials to. * @@ -401,7 +464,8 @@ internal object DocuSignManager { activity: Activity, envelopeId: String, recipientClientUserId: String, - listener: DSCaptiveSigningListener + listener: DSCaptiveSigningListener, + mintFailure: Throwable? = null ) { try { DocuSign.getInstance().getCustomSettingsDelegate() @@ -415,7 +479,11 @@ internal object DocuSignManager { } catch (e: Exception) { currentEnvelopeId = null val pending = pendingCompletion.getAndSet(null) - pending?.invoke(Result.failure(SigningFailedException(e.message ?: "Unknown error"))) + pending?.invoke( + Result.failure( + signingFailure(envelopeId, e, mintFailure, "DocuSign could not open the signing ceremony") + ) + ) } } @@ -439,12 +507,12 @@ internal object DocuSignManager { // Ahead of the compareAndSet on purpose: a rejected URL must not claim the pending slot, or a // later valid call would be refused as "already in progress". if (!isHttpsUrl(signingUrl)) { - completion(Result.failure(SigningFailedException("Signing URL must be a valid HTTPS URL"))) + completion(Result.failure(InvalidSigningUrlException())) return } if (!pendingCompletion.compareAndSet(null, completion)) { - completion(Result.failure(SigningFailedException("A signing session is already in progress"))) + completion(Result.failure(SigningInProgressException())) return } currentEnvelopeId = envelopeId @@ -481,10 +549,16 @@ internal object DocuSignManager { } catch (e: Exception) { // A mint failure must not be worse than not offering the strategy at all. Falling back to // the fetch path restores the default behaviour exactly, so this can only add a way to - // succeed. + // succeed. The mint's failure rides along so a fetch failure still names it. activity.runOnUiThread { if (!canLaunchOn(activity, envelopeId, completion)) return@runOnUiThread - launchViaEnvelopeFetch(activity, envelopeId, recipientClientUserId, listener) + launchViaEnvelopeFetch( + activity, + envelopeId, + recipientClientUserId, + captiveSigningListener(mintFailure = e), + mintFailure = e + ) } return@thread } @@ -515,7 +589,11 @@ internal object DocuSignManager { } catch (e: Exception) { currentEnvelopeId = null val pending = pendingCompletion.getAndSet(null) - pending?.invoke(Result.failure(SigningFailedException(e.message ?: "Unknown error"))) + pending?.invoke( + Result.failure( + signingFailure(envelopeId, e, null, "DocuSign could not open the signing ceremony") + ) + ) } } @@ -586,7 +664,7 @@ internal object DocuSignManager { val stream = if (code in 200..299) connection.inputStream else connection.errorStream val text = stream?.bufferedReader()?.use { it.readText() } ?: "" if (code !in 200..299) { - throw IOException("recipient view request failed with HTTP $code") + throw DocuSignHttpException.from(code, text) } JSONObject(text).getString("url") } finally { @@ -612,9 +690,9 @@ internal object DocuSignManager { pendingCompletion.getAndSet(null)?.invoke(Result.success(outcome)) } - fun handleSigningError(envelopeId: String?, errorCode: String, errorMessage: String) { - module?.emitSigningError(envelopeId, errorCode, errorMessage) + /** The module emits onSigningError when it settles the failure, so this does not. */ + fun handleSigningError(failure: DocuSignFailure) { currentEnvelopeId = null - pendingCompletion.getAndSet(null)?.invoke(Result.failure(SigningFailedException(errorMessage))) + pendingCompletion.getAndSet(null)?.invoke(Result.failure(failure)) } } diff --git a/android/src/main/java/expo/modules/docusign/DocuSignModule.kt b/android/src/main/java/expo/modules/docusign/DocuSignModule.kt index 7b1b88c..e13246e 100644 --- a/android/src/main/java/expo/modules/docusign/DocuSignModule.kt +++ b/android/src/main/java/expo/modules/docusign/DocuSignModule.kt @@ -87,7 +87,17 @@ class DocuSignModule : Module() { DocuSignManager.initialize(context, config.integratorKey, environment) promise.resolve(null) } catch (e: Exception) { - promise.reject("initialize_failed", e.message ?: "Unknown error", e) + // A runtime failure of the SDK itself, so it carries the exception's details rather than + // rejecting with a message alone. + settleFailure( + DocuSignFailure( + code = "initialize_failed", + message = "DocuSign SDK could not be initialized: ${e.message ?: e.javaClass.simpleName}", + details = FailureDetails.from(e) + ), + null, + promise + ) } } @@ -105,24 +115,24 @@ class DocuSignModule : Module() { onSuccess = { info -> promise.resolve( mapOf( - "accountId" to info.accountId, - "userId" to info.userId, - "userName" to info.userName, - "email" to info.email + "status" to "success", + "account" to mapOf( + "accountId" to info.accountId, + "userId" to info.userId, + "userName" to info.userName, + "email" to info.email + ) ) ) }, - onFailure = { error -> - emitSigningError(null, "login_failed", error.message ?: "Unknown error") - promise.reject("login_failed", error.message ?: "Unknown error", error as? Exception) - } + onFailure = { error -> settleFailure(error, null, promise) } ) } } AsyncFunction("presentCaptiveSigning") { params: CaptiveSigningRecord, promise: Promise -> val activity: Activity = appContext.activityProvider?.currentActivity - ?: throw Exceptions.MissingActivity() + ?: throw PresentationException("no foreground Activity to present from") DocuSignManager.presentCaptiveSigning( activity = activity, @@ -133,30 +143,15 @@ class DocuSignModule : Module() { launchStrategy = CaptiveSigningLaunchStrategy.fromString(params.launchStrategy) ) { result -> result.fold( - onSuccess = { outcome -> - promise.resolve( - mapOf( - "status" to outcome.status, - "envelopeId" to outcome.envelopeId, - "errorCode" to outcome.errorCode, - "errorMessage" to outcome.errorMessage - ) - ) - }, - onFailure = { error -> - // No emitSigningError here. handleSigningError already emits, so emitting again - // delivered two events per failure and flattened recipient_signing_failed into - // signing_failed. Failures that never reach the manager (not initialized, not logged - // in) are programming errors and reject without an event, matching iOS. - promise.reject(codeOf(error), error.message ?: "Unknown error", error as? Exception) - } + onSuccess = { outcome -> promise.resolve(outcome.toPayload()) }, + onFailure = { error -> settleFailure(error, params.envelopeId, promise) } ) } } AsyncFunction("presentCaptiveSigningWithUrl") { params: CaptiveSigningUrlRecord, promise: Promise -> val activity: Activity = appContext.activityProvider?.currentActivity - ?: throw Exceptions.MissingActivity() + ?: throw PresentationException("no foreground Activity to present from") DocuSignManager.presentCaptiveSigningWithUrl( activity = activity, @@ -165,19 +160,8 @@ class DocuSignModule : Module() { recipientId = params.recipientId.takeIf { it.isNotEmpty() } ) { result -> result.fold( - onSuccess = { outcome -> - promise.resolve( - mapOf( - "status" to outcome.status, - "envelopeId" to outcome.envelopeId, - "errorCode" to outcome.errorCode, - "errorMessage" to outcome.errorMessage - ) - ) - }, - onFailure = { error -> - promise.reject(codeOf(error), error.message ?: "Unknown error", error as? Exception) - } + onSuccess = { outcome -> promise.resolve(outcome.toPayload()) }, + onFailure = { error -> settleFailure(error, params.envelopeId, promise) } ) } } @@ -203,12 +187,22 @@ class DocuSignModule : Module() { } /** - * The rejection code for a manager failure. Every exception this module raises is a - * CodedException carrying an explicit code, so callers can tell not_initialized from - * not_logged_in from signing_failed instead of receiving signing_failed for all three. + * The one place a failure is settled. + * + * A runtime failure resolves with its details, because a rejection reaches JS with only a code + * and a message and would drop them. A caller mistake rejects with its own code. */ - private fun codeOf(error: Throwable): String = - (error as? CodedException)?.code ?: "signing_failed" + private fun settleFailure(error: Throwable, envelopeId: String?, promise: Promise) { + when (error) { + is DocuSignFailure -> { + val payload = error.toPayload(envelopeId) + sendEvent("onSigningError", payload) + promise.resolve(payload + ("status" to "error")) + } + is CodedException -> promise.reject(error.code, error.message ?: "Unknown error", error) + else -> promise.reject("unexpected", error.message ?: "Unknown error", error) + } + } internal fun emitSigningComplete(envelopeId: String) { sendEvent("onSigningComplete", mapOf("envelopeId" to envelopeId)) @@ -217,15 +211,4 @@ class DocuSignModule : Module() { internal fun emitSigningCancelled(envelopeId: String, reason: String?) { sendEvent("onSigningCancelled", mapOf("envelopeId" to envelopeId, "reason" to reason)) } - - internal fun emitSigningError(envelopeId: String?, errorCode: String, errorMessage: String) { - sendEvent( - "onSigningError", - mapOf( - "envelopeId" to envelopeId, - "errorCode" to errorCode, - "errorMessage" to errorMessage - ) - ) - } } diff --git a/docs/ERROR_HANDLING.md b/docs/ERROR_HANDLING.md new file mode 100644 index 0000000..d269349 --- /dev/null +++ b/docs/ERROR_HANDLING.md @@ -0,0 +1,481 @@ +# Error handling guide for `react-native-docusign` + +Every failure from `initialize`, `loginWithAccessToken`, `presentCaptiveSigning` and `presentCaptiveSigningWithUrl` rejects with a `DocuSignError`. This guide covers what that error contains, how to turn it into a message your users understand, and how to send it to Amplitude, New Relic, Sentry or any other tool so you can tell what failed in production. + +The package does two things and nothing else: it builds a structured error, and it hands that error to your code. It shows no UI, ships no user-facing strings, logs nothing in production and depends on no analytics SDK. Your app decides what users see and where errors are recorded. + +Every code sample under [Working examples](#working-examples) is a real file in [`examples/error-handling/`](../examples/error-handling), type-checked in CI against the real Amplitude, New Relic, Sentry and i18next packages. + +## The error model + +```ts +class DocuSignError extends Error { + code: DocuSignErrorCode; // what failed + reason: DocuSignErrorReason; // why it failed, when the package can tell + message: string; // English, for developers, never for users + envelopeId?: string; + native?: { + domain?: string; // iOS NSError domain, or the Android exception class + code?: string; // iOS NSError code, or the Android SDK error code + message?: string; + underlying?: { domain?: string; code?: string; message?: string }; + }; + http?: { + status: number; + docusignErrorCode?: string; // DocuSign's own code from the response body + docusignMessage?: string; + }; + toAttributes(): DocuSignErrorAttributes; // flat attributes for analytics tools +} +``` + +`code` says what failed and `reason` says why. `native` and `http` carry the raw facts behind both, for developers. `underlying` is the lower-level error when one is known: `NSUnderlyingErrorKey` on iOS, the root of the exception's cause chain on Android, or the transport error of a request the package made itself. + +### Codes + +A usage code means your app called the package wrongly. Every other code is a failure at runtime. + +| `code` | Kind | When | Typical cause | +| --- | --- | --- | --- | +| `not_initialized` | usage | a call before `initialize()` resolved | a missing `await`, or a call after `reset()` without initializing again | +| `not_logged_in` | usage | `presentCaptiveSigning` before `loginWithAccessToken()` resolved | presenting too early, or after `logout()` or `endSigningSession()` | +| `signing_in_progress` | usage | a present call while a ceremony is already open | a double tap, an effect firing twice, a retry while the first ceremony is still open | +| `invalid_signing_url` | usage | `signingUrl` is empty or not `https` | the backend did not return the URL, or the field was not passed | +| `presentation_failed` | usage | no screen to present from | presenting while the app is in the background or mid navigation transition | +| `initialize_failed` | runtime | the SDK could not be configured | an invalid environment or integrator key | +| `login_failed` | runtime | the SDK rejected the login | see `reason` | +| `signing_failed` | runtime | the ceremony could not open, or ended with an error | see `reason` | +| `unexpected` | runtime | anything the package did not anticipate | the original code is kept in `native.code` | + +### Reasons + +`reason` is set only from facts the package can verify. When no fact identifies the cause it is `unknown`, and `native` still carries everything the SDK reported. A later minor release can make an `unknown` more specific, so always handle `unknown`. + +| `reason` | Set when | Who fixes it | +| --- | --- | --- | +| `usage` | the code is a usage code | the app developer | +| `network` | the error, or its underlying error, is `NSURLErrorDomain` on iOS, or `UnknownHostException`, `SocketTimeoutException` or `ConnectException` on Android | nobody, the connection was lost | +| `auth` | a DocuSign request returned HTTP 401 or 403 | the backend: the access token is expired or wrongly scoped | +| `configuration` | login failed although `/oauth/userinfo` accepted the same token | DocuSign admin (Mobile SDK enabled, app ID allowed), or on iOS the app's `AppIdentifierPrefix` | +| `recipient` | DocuSign answered `UNKNOWN_ENVELOPE_RECIPIENT` | the backend: the envelope's recipient does not match the `clientUserId`, name or email the app sends | +| `unknown` | none of the above | investigate with `native` and `http` | + +The rules apply in the order of the table. A lost connection outranks an HTTP status seen earlier in the same flow, because the connection is what stopped the call. + +## What the user sees + +Show copy chosen by `reason`, never `error.message`. The message is developer text, like a log line. It is English, it can name SDK internals, and it can change between releases. + +The package ships no user-facing strings, because it cannot know your languages, your tone, or what the user was doing. A `reason` is a stable value that does not depend on language, and your app turns it into its own translated text. + +| `reason` | Suggested copy | Retry automatically | +| --- | --- | --- | +| `network` | "Your connection dropped. Check your internet and try again." | yes | +| `auth` | "Your signing session expired. Please try again." | only after fetching a fresh session | +| `configuration` | "Document signing isn't available right now. Please try again later." | no | +| `recipient` | "We couldn't open this document for you. Please contact the sender." | no | +| `usage` | "Something went wrong while opening the document. Please try again." | no, it is a bug | +| `unknown` | "Something went wrong while opening the document. Please try again." | yes | + +A cancelled ceremony is not an error. The promise resolves with `status: 'cancelled'`, so show nothing. + +In a component, one line picks the copy: + +```ts +showToast({ title: t(DOCUSIGN_ERROR_COPY_KEY[error.reason]) }); +``` + +`DOCUSIGN_ERROR_COPY_KEY` is typed as `Record`, so TypeScript flags the missing entry if a release adds a reason. See [`copy.ts`](#copyts), [`locales`](#localesenjson-and-localesitjson) and [`i18n.ts`](#i18nts) below. + +## Retrying + +Retry only what a retry can fix. A lost connection or an unknown SDK error may succeed on a second attempt. An expired token, an account that is not set up, a mismatched recipient or a bug in the calling code will fail again the same way, and retrying them only doubles the wait before the user sees the message. + +For `auth`, fetch a new session from your backend and start over rather than retrying the same session. + +Between attempts, await `endSigningSession()` so the next attempt does not race the teardown of the one that failed. See [`retry.ts`](#retryts) and [`useSigningWithErrors.ts`](#usesigningwitherrorsts). + +## Sending errors to your tools + +There are two places to plug in. Either catch at the call site, or register one listener at startup with `addSigningErrorListener`. The listener receives every `DocuSignError`, usage errors included, exactly once each, before the call rejects. Registering once is usually the better choice, because no call site can forget to report. + +`toAttributes()` flattens the error into primitive values that every tool accepts: + +```ts +{ + docusign_code: 'signing_failed', + docusign_reason: 'network', + docusign_envelope_id: '9f3c…', + docusign_native_domain: 'NSURLErrorDomain', + docusign_native_code: '-1009', + docusign_http_status: 401, // only when an HTTP response was involved + docusign_api_error_code: 'UNKNOWN_ENVELOPE_RECIPIENT', // only when DocuSign returned one +} +``` + +Keys with no value are omitted. There are no messages on purpose: every key is low-cardinality, so a dashboard can group on any of them. When you also want the message, send `error.message` yourself. + +See [`report.ts`](#reportts) for Amplitude, New Relic and Sentry. + +- **Amplitude:** one `docusign_failed` event per failure, with the attributes as event properties. +- **New Relic:** `recordError(error, false, attributes)` records a handled, non-fatal error. React Native agent 1.9.0 and later store it as a `MobileJSError` event with the attributes attached. Agents before 1.9.0 used `MobileHandledException`. Version 1.9.x of the agent reaches its native module at import time, so importing it in a build where the native module is not linked (Expo Go, for example) throws at startup. +- **Sentry:** `captureException` with the attributes as tags, so every issue can be filtered by reason and code. + +### What is safe to send + +The package never writes the access token or a signing URL into an error itself. Text that comes from the DocuSign SDKs or from DocuSign's responses can echo them back, so before an error reaches your code the package removes JWTs, `Bearer` credentials, URL query strings and URL path segments of 20 or more token characters from `message`, `native` and `http`. + +That redaction is pattern-based, which makes it a strong default rather than a guarantee: a secret in a shape those patterns do not recognise would pass through. The attributes from `toAttributes()` carry no message text at all, only codes, domains, statuses and the envelope id, so they are the safest thing to send. + +Your app still owns redaction of its own context. If you add a user's email or name next to the error, or forward `error.message`, scrub it the way you scrub the rest of your analytics. + +## Reading it in production + +**Amplitude.** Chart the `docusign_failed` event grouped by `docusign_reason` to see the split between connection loss, backend problems, configuration and bugs. Then filter to `docusign_reason = unknown` and group by `docusign_native_domain` and `docusign_native_code` to see which SDK errors actually happen. + +**New Relic (agent 1.9.0 and later).** + +```sql +SELECT count(*) FROM MobileJSError +WHERE docusign_reason IS NOT NULL +FACET docusign_reason, docusign_code +SINCE 7 days ago +``` + +To drill into unclassified SDK errors: + +```sql +SELECT count(*) FROM MobileJSError +WHERE docusign_reason = 'unknown' +FACET docusign_native_domain, docusign_native_code +SINCE 7 days ago +``` + +**Reading a spike.** A rise in `auth` or `recipient` points at the backend: token minting, token lifetime, or envelope creation. A rise in `configuration` points at DocuSign admin or a recent change to the app's native configuration. A rise in `network` is usually the users' connections. A rise in `usage` after a release is a bug in that release. + +## Locating a `usage` bug + +`code` names the mistake, and `message` names the fix. In development, a usage error also prints one console warning, such as `[react-native-docusign] not_initialized: Call initialize() first.`. A catch that turns every error into a generic toast therefore cannot hide the bug. Production builds print nothing. + +In production the stack trace ends inside the package, because the error is built after an `await`. The context your app logs next to the error is what locates the call site: the screen, the step of the flow, the attempt number. + +## Worked examples + +Values such as SDK codes and messages are illustrative. Field names, codes and reasons are exact. + +### Forgotten `initialize()` + +```ts +{ code: 'not_initialized', reason: 'usage', message: 'DocuSign SDK has not been initialized. Call initialize() first.' } +``` + +User reads the generic copy. The development warning points at the fix. Attributes: `{ docusign_code: 'not_initialized', docusign_reason: 'usage' }`. + +### Expired access token + +```ts +{ + code: 'login_failed', + reason: 'auth', + message: 'DocuSign rejected the access token. Mint a new token with the signature and impersonation scopes. (…)', + http: { status: 401 }, +} +``` + +User reads "Your signing session expired. Please try again.", and the app fetches a fresh session before trying again. Attributes include `docusign_http_status: 401`. + +### Valid token, account not set up for mobile + +```ts +{ + code: 'login_failed', + reason: 'configuration', + message: 'DocuSign rejected a valid access token. Check that AppIdentifierPrefix is set in Info.plist, that the Mobile SDK is enabled for integration key …', + http: { status: 200 }, +} +``` + +User reads "Document signing isn't available right now." with no retry. The fix is in DocuSign admin, or in the app's `Info.plist` on iOS. + +### Recipient does not match the envelope + +```ts +{ + code: 'signing_failed', + reason: 'recipient', + envelopeId: '9f3c…', + http: { + status: 400, + docusignErrorCode: 'UNKNOWN_ENVELOPE_RECIPIENT', + docusignMessage: 'The recipient you have identified is not a valid recipient of the specified envelope.', + }, +} +``` + +User reads "We couldn't open this document for you. Please contact the sender." with no retry. The backend created the envelope with a different `clientUserId`, name or email than the session sends. On Android this arrives through the `signingUrl` launch strategy, which mints the recipient view itself. + +### Connection lost + +```ts +{ + code: 'signing_failed', + reason: 'network', + envelopeId: '9f3c…', + native: { domain: 'NSURLErrorDomain', code: '-1009', message: 'The Internet connection appears to be offline.' }, +} +``` + +User reads "Your connection dropped. Check your internet and try again." and a retry makes sense. + +### An SDK error the package cannot classify + +```ts +{ + code: 'signing_failed', + reason: 'unknown', + envelopeId: '9f3c…', + native: { domain: 'com.docusign.androidsdk.exceptions.DSSigningException', code: '…', message: '…' }, +} +``` + +User reads the generic copy, and the app retries once. Group by `docusign_native_domain` and `docusign_native_code` to learn which of these occur. Once one is understood, a minor release can give it a specific reason. + +## Working examples + +### copy.ts + + +```ts +import type { TFunction } from 'i18next'; +import type { DocuSignError, DocuSignErrorReason } from 'react-native-docusign'; + +/** + * One translation key per reason. Typed as a Record so TypeScript flags the + * gap if a later release adds a reason. Show this to users, never + * `error.message`, which is developer text. + */ +export const DOCUSIGN_ERROR_COPY_KEY: Record = { + network: 'docusign.errors.network', + auth: 'docusign.errors.sessionExpired', + configuration: 'docusign.errors.unavailable', + recipient: 'docusign.errors.contactSender', + usage: 'docusign.errors.generic', + unknown: 'docusign.errors.generic', +}; + +export function docuSignErrorCopy(t: TFunction, error: DocuSignError): string { + return t(DOCUSIGN_ERROR_COPY_KEY[error.reason]); +} + +/** The same mapping for an app that ships a single language and no i18n library. */ +export const DOCUSIGN_ERROR_COPY_EN: Record = { + network: 'Your connection dropped. Check your internet and try again.', + auth: 'Your signing session expired. Please try again.', + configuration: + "Document signing isn't available right now. Please try again later.", + recipient: + "We couldn't open this document for you. Please contact the sender.", + usage: 'Something went wrong while opening the document. Please try again.', + unknown: 'Something went wrong while opening the document. Please try again.', +}; +``` + +### locales/en.json and locales/it.json + + +```json +{ + "docusign": { + "errors": { + "network": "Your connection dropped. Check your internet and try again.", + "sessionExpired": "Your signing session expired. Please try again.", + "unavailable": "Document signing isn't available right now. Please try again later.", + "contactSender": "We couldn't open this document for you. Please contact the sender.", + "generic": "Something went wrong while opening the document. Please try again." + } + } +} +``` + + +```json +{ + "docusign": { + "errors": { + "network": "La connessione si è interrotta. Controlla la rete e riprova.", + "sessionExpired": "La sessione di firma è scaduta. Riprova.", + "unavailable": "La firma dei documenti non è disponibile al momento. Riprova più tardi.", + "contactSender": "Non riusciamo ad aprire questo documento. Contatta il mittente.", + "generic": "Si è verificato un errore durante l'apertura del documento. Riprova." + } + } +} +``` + +### i18n.ts + + +```ts +import { createInstance } from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import en from './locales/en.json'; +import it from './locales/it.json'; + +export const i18n = createInstance(); + +export async function initI18n(language: string): Promise { + await i18n.use(initReactI18next).init({ + lng: language, + fallbackLng: 'en', + resources: { + en: { translation: en }, + it: { translation: it }, + }, + interpolation: { escapeValue: false }, + }); +} +``` + +### report.ts + + +```ts +import { track } from '@amplitude/analytics-react-native'; +import * as Sentry from '@sentry/react-native'; +import NewRelic from 'newrelic-react-native-agent'; +import { + addSigningErrorListener, + DocuSignError, + DocuSignSubscription, +} from 'react-native-docusign'; + +/** One event per failure, grouped by `docusign_reason` in charts. */ +export function reportToAmplitude(error: DocuSignError): void { + track('docusign_failed', error.toAttributes()); +} + +/** + * A handled, non-fatal error. Agent 1.9.0 and later store it as a + * `MobileJSError` event with the attributes attached. + */ +export function reportToNewRelic(error: DocuSignError): void { + NewRelic.recordError(error, false, error.toAttributes()).catch( + () => undefined, + ); +} + +export function reportToSentry(error: DocuSignError): void { + Sentry.captureException(error, { tags: error.toAttributes() }); +} + +/** + * Registers one listener that forwards every DocuSign failure, caller mistakes + * included, to each tool. Call it once at startup. Each retry attempt that + * fails is reported, because each is a real failure. + */ +export function installDocuSignErrorReporting(): DocuSignSubscription { + return addSigningErrorListener((error) => { + reportToAmplitude(error); + reportToNewRelic(error); + reportToSentry(error); + }); +} +``` + +Keep the adapters for the tools you use and delete the rest. + +### retry.ts + + +```ts +import type { DocuSignError, DocuSignErrorReason } from 'react-native-docusign'; + +/** + * Whether trying the same session again can succeed. An expired token (`auth`) + * needs a fresh session from your backend first, so it does not retry as is. + */ +const RETRYABLE: Record = { + network: true, + unknown: true, + auth: false, + configuration: false, + recipient: false, + usage: false, +}; + +export function shouldRetry(error: DocuSignError): boolean { + return RETRYABLE[error.reason]; +} +``` + +### useSigningWithErrors.ts + + +```ts +import { + DocuSignConfig, + DocuSignError, + DocuSignSigningState, + endSigningSession, + SigningResult, + SigningSession, + useDocuSignSigning, +} from 'react-native-docusign'; + +import { shouldRetry } from './retry'; + +const MAX_ATTEMPTS = 2; + +export type UseSigningWithErrorsOptions = { + config: DocuSignConfig; + /** Receives the final failure. Map `error.reason` to translated copy here, for example in a toast. */ + onFailure: (error: DocuSignError) => void; +}; + +export type UseSigningWithErrorsReturn = { + state: DocuSignSigningState; + sign: (session: SigningSession) => Promise; +}; + +/** + * Wraps `useDocuSignSigning` with a retry policy driven by `reason`. Logging is + * not done here: `installDocuSignErrorReporting` already sees every failure. + */ +export function useSigningWithErrors({ + config, + onFailure, +}: UseSigningWithErrorsOptions): UseSigningWithErrorsReturn { + const signing = useDocuSignSigning({ config }); + + const sign = async (session: SigningSession) => { + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + try { + return await signing.startSigning(session); + } catch (error) { + if (!(error instanceof DocuSignError)) throw error; + if (attempt === MAX_ATTEMPTS || !shouldRetry(error)) { + onFailure(error); + return null; + } + // Awaited, unlike the hook's reset(), so the next attempt does not race + // the teardown of the one that failed. + await endSigningSession(); + } + } + return null; + }; + + return { state: signing.state, sign }; +} +``` + +Wiring it into a screen, with `installDocuSignErrorReporting()` already called once at startup: + +```tsx +const { t } = useTranslation(); +const { state, sign } = useSigningWithErrors({ + config, + onFailure: (error) => showToast({ title: docuSignErrorCopy(t, error) }), +}); +``` diff --git a/examples/error-handling/copy.ts b/examples/error-handling/copy.ts new file mode 100644 index 0000000..c5591e4 --- /dev/null +++ b/examples/error-handling/copy.ts @@ -0,0 +1,32 @@ +import type { TFunction } from 'i18next'; +import type { DocuSignError, DocuSignErrorReason } from 'react-native-docusign'; + +/** + * One translation key per reason. Typed as a Record so TypeScript flags the + * gap if a later release adds a reason. Show this to users, never + * `error.message`, which is developer text. + */ +export const DOCUSIGN_ERROR_COPY_KEY: Record = { + network: 'docusign.errors.network', + auth: 'docusign.errors.sessionExpired', + configuration: 'docusign.errors.unavailable', + recipient: 'docusign.errors.contactSender', + usage: 'docusign.errors.generic', + unknown: 'docusign.errors.generic', +}; + +export function docuSignErrorCopy(t: TFunction, error: DocuSignError): string { + return t(DOCUSIGN_ERROR_COPY_KEY[error.reason]); +} + +/** The same mapping for an app that ships a single language and no i18n library. */ +export const DOCUSIGN_ERROR_COPY_EN: Record = { + network: 'Your connection dropped. Check your internet and try again.', + auth: 'Your signing session expired. Please try again.', + configuration: + "Document signing isn't available right now. Please try again later.", + recipient: + "We couldn't open this document for you. Please contact the sender.", + usage: 'Something went wrong while opening the document. Please try again.', + unknown: 'Something went wrong while opening the document. Please try again.', +}; diff --git a/examples/error-handling/i18n.ts b/examples/error-handling/i18n.ts new file mode 100644 index 0000000..2cf9b3f --- /dev/null +++ b/examples/error-handling/i18n.ts @@ -0,0 +1,19 @@ +import { createInstance } from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import en from './locales/en.json'; +import it from './locales/it.json'; + +export const i18n = createInstance(); + +export async function initI18n(language: string): Promise { + await i18n.use(initReactI18next).init({ + lng: language, + fallbackLng: 'en', + resources: { + en: { translation: en }, + it: { translation: it }, + }, + interpolation: { escapeValue: false }, + }); +} diff --git a/examples/error-handling/locales/en.json b/examples/error-handling/locales/en.json new file mode 100644 index 0000000..afc32c3 --- /dev/null +++ b/examples/error-handling/locales/en.json @@ -0,0 +1,11 @@ +{ + "docusign": { + "errors": { + "network": "Your connection dropped. Check your internet and try again.", + "sessionExpired": "Your signing session expired. Please try again.", + "unavailable": "Document signing isn't available right now. Please try again later.", + "contactSender": "We couldn't open this document for you. Please contact the sender.", + "generic": "Something went wrong while opening the document. Please try again." + } + } +} diff --git a/examples/error-handling/locales/it.json b/examples/error-handling/locales/it.json new file mode 100644 index 0000000..d846079 --- /dev/null +++ b/examples/error-handling/locales/it.json @@ -0,0 +1,11 @@ +{ + "docusign": { + "errors": { + "network": "La connessione si è interrotta. Controlla la rete e riprova.", + "sessionExpired": "La sessione di firma è scaduta. Riprova.", + "unavailable": "La firma dei documenti non è disponibile al momento. Riprova più tardi.", + "contactSender": "Non riusciamo ad aprire questo documento. Contatta il mittente.", + "generic": "Si è verificato un errore durante l'apertura del documento. Riprova." + } + } +} diff --git a/examples/error-handling/report.ts b/examples/error-handling/report.ts new file mode 100644 index 0000000..a291ac0 --- /dev/null +++ b/examples/error-handling/report.ts @@ -0,0 +1,40 @@ +import { track } from '@amplitude/analytics-react-native'; +import * as Sentry from '@sentry/react-native'; +import NewRelic from 'newrelic-react-native-agent'; +import { + addSigningErrorListener, + DocuSignError, + DocuSignSubscription, +} from 'react-native-docusign'; + +/** One event per failure, grouped by `docusign_reason` in charts. */ +export function reportToAmplitude(error: DocuSignError): void { + track('docusign_failed', error.toAttributes()); +} + +/** + * A handled, non-fatal error. Agent 1.9.0 and later store it as a + * `MobileJSError` event with the attributes attached. + */ +export function reportToNewRelic(error: DocuSignError): void { + NewRelic.recordError(error, false, error.toAttributes()).catch( + () => undefined, + ); +} + +export function reportToSentry(error: DocuSignError): void { + Sentry.captureException(error, { tags: error.toAttributes() }); +} + +/** + * Registers one listener that forwards every DocuSign failure, caller mistakes + * included, to each tool. Call it once at startup. Each retry attempt that + * fails is reported, because each is a real failure. + */ +export function installDocuSignErrorReporting(): DocuSignSubscription { + return addSigningErrorListener((error) => { + reportToAmplitude(error); + reportToNewRelic(error); + reportToSentry(error); + }); +} diff --git a/examples/error-handling/retry.ts b/examples/error-handling/retry.ts new file mode 100644 index 0000000..41950c4 --- /dev/null +++ b/examples/error-handling/retry.ts @@ -0,0 +1,18 @@ +import type { DocuSignError, DocuSignErrorReason } from 'react-native-docusign'; + +/** + * Whether trying the same session again can succeed. An expired token (`auth`) + * needs a fresh session from your backend first, so it does not retry as is. + */ +const RETRYABLE: Record = { + network: true, + unknown: true, + auth: false, + configuration: false, + recipient: false, + usage: false, +}; + +export function shouldRetry(error: DocuSignError): boolean { + return RETRYABLE[error.reason]; +} diff --git a/examples/error-handling/useSigningWithErrors.ts b/examples/error-handling/useSigningWithErrors.ts new file mode 100644 index 0000000..a91b093 --- /dev/null +++ b/examples/error-handling/useSigningWithErrors.ts @@ -0,0 +1,55 @@ +import { + DocuSignConfig, + DocuSignError, + DocuSignSigningState, + endSigningSession, + SigningResult, + SigningSession, + useDocuSignSigning, +} from 'react-native-docusign'; + +import { shouldRetry } from './retry'; + +const MAX_ATTEMPTS = 2; + +export type UseSigningWithErrorsOptions = { + config: DocuSignConfig; + /** Receives the final failure. Map `error.reason` to translated copy here, for example in a toast. */ + onFailure: (error: DocuSignError) => void; +}; + +export type UseSigningWithErrorsReturn = { + state: DocuSignSigningState; + sign: (session: SigningSession) => Promise; +}; + +/** + * Wraps `useDocuSignSigning` with a retry policy driven by `reason`. Logging is + * not done here: `installDocuSignErrorReporting` already sees every failure. + */ +export function useSigningWithErrors({ + config, + onFailure, +}: UseSigningWithErrorsOptions): UseSigningWithErrorsReturn { + const signing = useDocuSignSigning({ config }); + + const sign = async (session: SigningSession) => { + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + try { + return await signing.startSigning(session); + } catch (error) { + if (!(error instanceof DocuSignError)) throw error; + if (attempt === MAX_ATTEMPTS || !shouldRetry(error)) { + onFailure(error); + return null; + } + // Awaited, unlike the hook's reset(), so the next attempt does not race + // the teardown of the one that failed. + await endSigningSession(); + } + } + return null; + }; + + return { state: signing.state, sign }; +} diff --git a/ios/DocuSignError.swift b/ios/DocuSignError.swift index bf4ced3..51c5724 100644 --- a/ios/DocuSignError.swift +++ b/ios/DocuSignError.swift @@ -1,55 +1,66 @@ 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. +// Caller mistakes only. Runtime failures travel as DocuSignFailure so their details survive the +// bridge. 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 { +internal class InitializeFailedException: GenericException { override var code: String { - "not_initialized" + "initialize_failed" } override var reason: String { - "DocuSign SDK has not been initialized. Call initialize() first." + "DocuSign SDK could not be initialized: \(param)" } } -internal class NotLoggedInException: Exception { +internal class SigningInProgressException: Exception { override var code: String { - "not_logged_in" + "signing_in_progress" } override var reason: String { - "DocuSign SDK is not logged in. Call loginWithAccessToken() first." + "A signing session is already in progress. Wait for it to finish or call endSigningSession() first." } } -internal class PresentationException: GenericException { +internal class InvalidSigningUrlException: Exception { override var code: String { - "presentation_failed" + "invalid_signing_url" } override var reason: String { - "Failed to present DocuSign signing UI: \(param)" + "signingUrl must be a non-empty https URL." } } -internal class SigningFailedException: GenericException { +internal class NotInitializedException: Exception { override var code: String { - "signing_failed" + "not_initialized" } override var reason: String { - "DocuSign signing failed: \(param)" + "DocuSign SDK has not been initialized. Call initialize() first." } } -internal class LoginFailedException: GenericException { +internal class NotLoggedInException: Exception { override var code: String { - "login_failed" + "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 { - "DocuSign login failed: \(param)" + "Failed to present DocuSign signing UI: \(param)" } } diff --git a/ios/DocuSignFailure.swift b/ios/DocuSignFailure.swift new file mode 100644 index 0000000..5265b36 --- /dev/null +++ b/ios/DocuSignFailure.swift @@ -0,0 +1,104 @@ +import Foundation + +/// Facts about a runtime failure, sent to JS as flat keys. +/// +/// JS derives the failure's `reason` from these, so native code reports what it observed and never +/// guesses. Every field is optional because each failure knows a different subset. +internal struct FailureDetails { + var nativeDomain: String? + var nativeCode: String? + var nativeMessage: String? + var underlyingDomain: String? + var underlyingCode: String? + var underlyingMessage: String? + var httpStatus: Int? + var docusignErrorCode: String? + var docusignMessage: String? + + init() {} + + /// Reads the error's own domain and code plus `NSUnderlyingErrorKey`. The domain is kept because + /// the SDK raises errors from several domains, so a code on its own is ambiguous. + init(error: Error) { + let nsError = error as NSError + nativeDomain = nsError.domain + nativeCode = String(nsError.code) + nativeMessage = nsError.localizedDescription + if let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? NSError { + setUnderlying(underlying) + } + } + + /// Records a lower-level error without overwriting one the SDK already reported. + mutating func setUnderlyingIfAbsent(_ error: Error) { + guard underlyingDomain == nil else { return } + setUnderlying(error as NSError) + } + + private mutating func setUnderlying(_ error: NSError) { + underlyingDomain = error.domain + underlyingCode = String(error.code) + underlyingMessage = error.localizedDescription + } + + /// Keeps DocuSign's own error code and message from a REST or OAuth error body, which is the + /// only place a backend problem such as a mismatched recipient is named. + mutating func setResponse(status: Int, body: Data?) { + httpStatus = status + guard + let body = body, + let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any] + else { return } + docusignErrorCode = Self.nonEmpty(json["errorCode"]) ?? Self.nonEmpty(json["error"]) + docusignMessage = Self.nonEmpty(json["message"]) ?? Self.nonEmpty(json["error_description"]) + } + + private static func nonEmpty(_ value: Any?) -> String? { + guard let text = value as? String, !text.isEmpty else { return nil } + return text + } + + var payload: [String: Any] { + let values: [String: Any?] = [ + "nativeDomain": nativeDomain, + "nativeCode": nativeCode, + "nativeMessage": nativeMessage, + "underlyingDomain": underlyingDomain, + "underlyingCode": underlyingCode, + "underlyingMessage": underlyingMessage, + "httpStatus": httpStatus, + "docusignErrorCode": docusignErrorCode, + "docusignMessage": docusignMessage + ] + return values.compactMapValues { $0 } + } +} + +/// A runtime failure, as opposed to a caller mistake. +/// +/// Caller mistakes are thrown as coded exceptions and reject. A rejection crosses the Expo bridge +/// with only a code and a message, so runtime failures resolve with this payload instead and JS +/// turns it into a thrown `DocuSignError` with every detail intact. +internal struct DocuSignFailure: Error { + let code: String + let message: String + let details: FailureDetails + let envelopeId: String? + + init(code: String, message: String, details: FailureDetails, envelopeId: String? = nil) { + self.code = code + self.message = message + self.details = details + self.envelopeId = envelopeId + } + + func payload(fallbackEnvelopeId: String?) -> [String: Any] { + var payload = details.payload + payload["errorCode"] = code + payload["errorMessage"] = message + if let envelopeId = envelopeId ?? fallbackEnvelopeId, !envelopeId.isEmpty { + payload["envelopeId"] = envelopeId + } + return payload + } +} diff --git a/ios/DocuSignManager.swift b/ios/DocuSignManager.swift index abf4e3d..8116b18 100644 --- a/ios/DocuSignManager.swift +++ b/ios/DocuSignManager.swift @@ -80,7 +80,7 @@ internal final class DocuSignManager: NSObject { } guard let url = URL(string: host) else { - throw NotInitializedException() + throw InitializeFailedException("invalid DocuSign host URL \(host)") } self.integratorKey = integratorKey @@ -346,6 +346,9 @@ internal final class DocuSignManager: NSObject { } } + /// The SDK's login error rarely says why. A second call to `/oauth/userinfo` with the same token + /// separates an expired or wrongly scoped token (401 or 403) from a valid token the SDK still + /// refuses, which points at DocuSign admin configuration rather than the backend. private func classifyLoginFailure( accessToken: String, sdkError: Error, @@ -353,37 +356,42 @@ internal final class DocuSignManager: NSObject { integratorKey: String, completion: @escaping (Result) -> Void ) { - let sdkMsg = (sdkError as NSError).localizedDescription - let sdkCode = (sdkError as NSError).code - probeUserInfoStatus(accessToken: accessToken) { probe in - let enrichedMsg: String + var details = FailureDetails(error: sdkError) + let summary: String switch probe { - case .ok: - enrichedMsg = "SDK rejected a valid token. Likely causes: Mobile SDK not enabled for integration key \(integratorKey), or iOS bundle ID not whitelisted in DocuSign admin. Contact DocuSign support. (SDK: \(sdkMsg)) | \(diagnostic)" - case .unauthorized: - enrichedMsg = "Access token rejected by DocuSign /oauth/userinfo. Re-mint via JWT Bearer Grant with scope=signature impersonation. (SDK: \(sdkMsg)) | \(diagnostic)" - case .network(let netMsg): - enrichedMsg = "\(sdkMsg) (userinfo probe network error: \(netMsg)) | \(diagnostic)" + case .response(let status, let body): + details.setResponse(status: status, body: body) + if (200..<300).contains(status) { + summary = "DocuSign rejected a valid access token. Check that AppIdentifierPrefix is set in Info.plist, that the Mobile SDK is enabled for integration key \(integratorKey), and that the iOS bundle ID is allowed in DocuSign admin." + } else if status == 401 || status == 403 { + summary = "DocuSign rejected the access token. Mint a new token with the signature and impersonation scopes." + } else { + summary = "DocuSign login failed, and the userinfo check returned HTTP \(status)." + } + case .transportFailed(let error): + details.setUnderlyingIfAbsent(error) + summary = "DocuSign login failed, and the userinfo check could not reach DocuSign." + case .unavailable(let reason): + summary = "DocuSign login failed, and the userinfo check could not run: \(reason)." } - let enriched = NSError( - domain: "DocuSign", - code: sdkCode, - userInfo: [NSLocalizedDescriptionKey: enrichedMsg] - ) - completion(.failure(enriched)) + completion(.failure(DocuSignFailure( + code: "login_failed", + message: "\(summary) (\(diagnostic))", + details: details + ))) } } private enum UserInfoProbe { - case ok - case unauthorized - case network(String) + case response(status: Int, body: Data?) + case transportFailed(Error) + case unavailable(String) } private func probeUserInfoStatus(accessToken: String, completion: @escaping (UserInfoProbe) -> Void) { guard let base = oauthBaseURL() else { - completion(.network("no oauth base URL")) + completion(.unavailable("no OAuth base URL")) return } let url = base.appendingPathComponent("oauth/userinfo") @@ -393,22 +401,16 @@ internal final class DocuSignManager: NSObject { request.setValue("application/json", forHTTPHeaderField: "Accept") request.timeoutInterval = 10 - URLSession.shared.dataTask(with: request) { _, response, error in + URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { - completion(.network(error.localizedDescription)) + completion(.transportFailed(error)) return } guard let http = response as? HTTPURLResponse else { - completion(.network("no HTTP response")) + completion(.unavailable("no HTTP response")) return } - if (200..<300).contains(http.statusCode) { - completion(.ok) - } else if http.statusCode == 401 || http.statusCode == 403 { - completion(.unauthorized) - } else { - completion(.network("userinfo HTTP \(http.statusCode)")) - } + completion(.response(status: http.statusCode, body: data)) }.resume() } @@ -449,7 +451,7 @@ internal final class DocuSignManager: NSObject { completion: @escaping (Result) -> Void ) { guard let base = oauthBaseURL() else { - completion(.failure(LoginFailedException("Could not derive OAuth base URL"))) + completion(.failure(Self.userInfoFailure("Could not derive the DocuSign OAuth base URL.", FailureDetails()))) return } let url = base.appendingPathComponent("oauth/userinfo") @@ -461,22 +463,29 @@ internal final class DocuSignManager: NSObject { URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { - completion(.failure(LoginFailedException("userinfo request failed: \(error.localizedDescription)"))) + completion(.failure(Self.userInfoFailure( + "The DocuSign userinfo request failed: \(error.localizedDescription)", + FailureDetails(error: error) + ))) return } guard let http = response as? HTTPURLResponse else { - completion(.failure(LoginFailedException("userinfo: no HTTP response"))) + completion(.failure(Self.userInfoFailure("The DocuSign userinfo request returned no HTTP response.", FailureDetails()))) return } guard (200..<300).contains(http.statusCode), let data = data else { - let snippet = data.flatMap { String(data: $0, encoding: .utf8) } ?? "" - completion(.failure(LoginFailedException("userinfo HTTP \(http.statusCode): \(snippet.prefix(200))"))) + var details = FailureDetails() + details.setResponse(status: http.statusCode, body: data) + completion(.failure(Self.userInfoFailure( + "The DocuSign userinfo request returned HTTP \(http.statusCode).", + details + ))) return } do { let decoded = try JSONDecoder().decode(UserInfoPayload.self, from: data) guard let account = Self.pickAccount(from: decoded.accounts, preferredId: preferredAccountId) else { - completion(.failure(LoginFailedException("userinfo: no accounts in response"))) + completion(.failure(Self.userInfoFailure("The DocuSign userinfo response lists no accounts.", FailureDetails()))) return } let host = account.base_uri.hasSuffix("/restapi") ? account.base_uri : account.base_uri + "/restapi" @@ -489,11 +498,18 @@ internal final class DocuSignManager: NSObject { ) completion(.success(resolved)) } catch { - completion(.failure(LoginFailedException("userinfo decode error: \(error.localizedDescription)"))) + completion(.failure(Self.userInfoFailure( + "The DocuSign userinfo response could not be read.", + FailureDetails(error: error) + ))) } }.resume() } + private static func userInfoFailure(_ message: String, _ details: FailureDetails) -> DocuSignFailure { + DocuSignFailure(code: "login_failed", message: message, details: details) + } + private static func pickAccount(from accounts: [UserInfoAccount], preferredId: String) -> UserInfoAccount? { if !preferredId.isEmpty, let match = accounts.first(where: { $0.account_id == preferredId }) { return match @@ -644,25 +660,11 @@ internal final class DocuSignManager: NSObject { throw NotLoggedInException() } - var alreadyInFlight = false - stateQueue.sync { - if pendingCompletion != nil { - alreadyInFlight = true - } else { - currentEnvelopeId = envelopeId - pendingCompletion = completion - } - } - if alreadyInFlight { - throw SigningFailedException("A signing session is already in progress") - } + try claimPendingSlot(envelopeId: envelopeId, completion: completion) - guard let presentingViewController = Self.topmostViewController() else { - resolvePending(.failure(PresentationException("Could not find a view controller to present from"))) - throw PresentationException("Could not find a view controller to present from") - } - - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + guard let presentingViewController = self.presentingViewControllerOrSettle() else { return } let envelopesManager = DSMEnvelopesManager() envelopesManager.presentCaptiveSigning( withPresenting: presentingViewController, @@ -672,16 +674,53 @@ internal final class DocuSignManager: NSObject { recipientClientUserId: recipientClientUserId, animated: true, completion: { [weak self] (_: UIViewController?, error: Error?) in - guard let self = self else { return } - if let error = error { - self.resolvePending(.failure(error)) - } + guard let self = self, let error = error else { return } + self.resolvePending(.failure(Self.openFailure(error, envelopeId: envelopeId))) // Success/cancel path is driven by DSMSigningCompletedNotification / DSMSigningCancelledNotification } ) } } + /// Claims the single pending-signing slot, or throws when a ceremony is already open. + private func claimPendingSlot( + envelopeId: String, + completion: @escaping (Result) -> Void + ) throws { + var alreadyInFlight = false + stateQueue.sync { + if pendingCompletion != nil { + alreadyInFlight = true + } else { + currentEnvelopeId = envelopeId + pendingCompletion = completion + } + } + if alreadyInFlight { + throw SigningInProgressException() + } + } + + /// Looks the presenter up on the main thread, where UIKit requires it. Doing this on the queue + /// the JS call arrived on read `UIApplication.shared` off-main. Without a presenter the slot is + /// settled as a caller mistake, so the promise never hangs. + private func presentingViewControllerOrSettle() -> UIViewController? { + if let presentingViewController = Self.topmostViewController() { + return presentingViewController + } + resolvePending(.failure(PresentationException("Could not find a view controller to present from"))) + return nil + } + + private static func openFailure(_ error: Error, envelopeId: String) -> DocuSignFailure { + DocuSignFailure( + code: "signing_failed", + message: "DocuSign could not open the signing ceremony: \((error as NSError).localizedDescription)", + details: FailureDetails(error: error), + envelopeId: envelopeId + ) + } + /// Guards the URL handed to the SDK's URL overload. /// /// `DSMEnvelopesManager.presentCaptiveSigning(withPresenting:signingUrl:...)` validates nothing @@ -713,28 +752,14 @@ internal final class DocuSignManager: NSObject { // 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") + throw InvalidSigningUrlException() } - var alreadyInFlight = false - stateQueue.sync { - if pendingCompletion != nil { - alreadyInFlight = true - } else { - currentEnvelopeId = envelopeId - pendingCompletion = completion - } - } - if alreadyInFlight { - throw SigningFailedException("A signing session is already in progress") - } + try claimPendingSlot(envelopeId: envelopeId, completion: completion) - guard let presentingViewController = Self.topmostViewController() else { - resolvePending(.failure(PresentationException("Could not find a view controller to present from"))) - throw PresentationException("Could not find a view controller to present from") - } - - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + guard let presentingViewController = self.presentingViewControllerOrSettle() else { return } let envelopesManager = DSMEnvelopesManager() envelopesManager.presentCaptiveSigning( withPresenting: presentingViewController, @@ -743,10 +768,8 @@ internal final class DocuSignManager: NSObject { recipientId: recipientId, animated: true, completion: { [weak self] (_: UIViewController?, error: Error?) in - guard let self = self else { return } - if let error = error { - self.resolvePending(.failure(error)) - } + guard let self = self, let error = error else { return } + self.resolvePending(.failure(Self.openFailure(error, envelopeId: envelopeId))) } ) } @@ -757,6 +780,16 @@ internal final class DocuSignManager: NSObject { let envelopeId: String let errorCode: String? let errorMessage: String? + + var payload: [String: Any] { + let values: [String: Any?] = [ + "status": status, + "envelopeId": envelopeId, + "errorCode": errorCode, + "errorMessage": errorMessage + ] + return values.compactMapValues { $0 } + } } private func resolvePending(_ result: Result) { @@ -818,22 +851,16 @@ internal final class DocuSignManager: NSObject { let envelopeId = envelopeId(from: notification) let userInfo = notification.userInfo + // The SDK reports some failures through its cancel notification. Settling them as a failure + // keeps one rule for every consumer: resolved means completed or cancelled, and each failure + // rejects with its details. The module emits onSigningError when it settles the failure. if let sdkError = userInfo?[DSMErrorKey] as? Error { - let nsErr = sdkError as NSError - let errorMessage = nsErr.localizedDescription - let errorCode = String(nsErr.code) - let outcome = SigningOutcome( - status: "error", - envelopeId: envelopeId, - errorCode: errorCode, - errorMessage: errorMessage - ) - module?.sendEvent("onSigningError", [ - "envelopeId": envelopeId, - "errorCode": errorCode, - "errorMessage": errorMessage - ]) - resolvePending(.success(outcome)) + resolvePending(.failure(DocuSignFailure( + code: "signing_failed", + message: "DocuSign ended the signing ceremony with an error: \((sdkError as NSError).localizedDescription)", + details: FailureDetails(error: sdkError), + envelopeId: envelopeId + ))) return } diff --git a/ios/DocuSignModule.swift b/ios/DocuSignModule.swift index 1602970..d5e311d 100644 --- a/ios/DocuSignModule.swift +++ b/ios/DocuSignModule.swift @@ -56,7 +56,7 @@ public class DocuSignModule: Module { ) promise.resolve(nil) } catch { - promise.reject(error) + self.settleFailure(error, envelopeId: nil, promise: promise) } } @@ -74,21 +74,20 @@ public class DocuSignModule: Module { switch result { case .success(let info): promise.resolve([ - "accountId": info.accountId, - "userId": info.userId, - "userName": info.userName, - "email": info.email + "status": "success", + "account": [ + "accountId": info.accountId, + "userId": info.userId, + "userName": info.userName, + "email": info.email + ] ]) case .failure(let error): - self.sendEvent("onSigningError", [ - "errorCode": "login_failed", - "errorMessage": error.localizedDescription - ]) - promise.reject(LoginFailedException(error.localizedDescription)) + self.settleFailure(error, envelopeId: nil, promise: promise) } } } catch { - promise.reject(error) + self.settleFailure(error, envelopeId: nil, promise: promise) } } @@ -102,28 +101,13 @@ public class DocuSignModule: Module { ) { result in switch result { case .success(let outcome): - promise.resolve([ - "status": outcome.status, - "envelopeId": outcome.envelopeId, - "errorCode": outcome.errorCode as Any, - "errorMessage": outcome.errorMessage as Any - ]) + promise.resolve(outcome.payload) 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": code, - "errorMessage": error.localizedDescription - ]) - promise.reject(code, error.localizedDescription) + self.settleFailure(error, envelopeId: params.envelopeId, promise: promise) } } } catch { - promise.reject(error) + self.settleFailure(error, envelopeId: params.envelopeId, promise: promise) } } @@ -136,28 +120,13 @@ public class DocuSignModule: Module { ) { result in switch result { case .success(let outcome): - promise.resolve([ - "status": outcome.status, - "envelopeId": outcome.envelopeId, - "errorCode": outcome.errorCode as Any, - "errorMessage": outcome.errorMessage as Any - ]) + promise.resolve(outcome.payload) 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": code, - "errorMessage": error.localizedDescription - ]) - promise.reject(code, error.localizedDescription) + self.settleFailure(error, envelopeId: params.envelopeId, promise: promise) } } } catch { - promise.reject(error) + self.settleFailure(error, envelopeId: params.envelopeId, promise: promise) } } @@ -182,4 +151,25 @@ public class DocuSignModule: Module { } } } + + /// The one place a failure is settled. + /// + /// A runtime failure resolves with its details, because a rejection reaches JS with only a code + /// and a message and would drop them. A caller mistake rejects. The message comes from + /// `CodedError.description`, since Expo's `Exception` is not a `LocalizedError` and its + /// `localizedDescription` is Foundation's generic text. + private func settleFailure(_ error: Error, envelopeId: String?, promise: Promise) { + if let failure = error as? DocuSignFailure { + var payload = failure.payload(fallbackEnvelopeId: envelopeId) + sendEvent("onSigningError", payload) + payload["status"] = "error" + promise.resolve(payload) + return + } + let codedError = error as? CodedError + promise.reject( + codedError?.code ?? "unexpected", + codedError?.description ?? error.localizedDescription + ) + } } diff --git a/jest.config.js b/jest.config.js index e9a823e..796539b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,5 +15,17 @@ module.exports = { lines: 80, statements: 80, }, + 'src/api.ts': { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/DocuSignError.ts': { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, }, }; diff --git a/jest.setup.js b/jest.setup.js index 396ec6b..89fb3ad 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -2,6 +2,14 @@ jest.mock('./src/DocuSignModule', () => ({ __esModule: true, default: { + initialize: jest.fn(), + loginWithAccessToken: jest.fn(), + presentCaptiveSigning: jest.fn(), presentCaptiveSigningWithUrl: jest.fn(), + logout: jest.fn(), + isLoggedIn: jest.fn(), + endSigningSession: jest.fn(), + reset: jest.fn(), + addListener: jest.fn(() => ({ remove: jest.fn() })), }, })); diff --git a/package-lock.json b/package-lock.json index a8eff6c..9dfff06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,16 +12,21 @@ "adm-zip": "^0.5.17" }, "devDependencies": { + "@amplitude/analytics-react-native": "^1.8.0", "@react-native/jest-preset": "^0.85.2", + "@sentry/react-native": "^8.26.0", "@testing-library/react-native": "^13.3.3", "@types/adm-zip": "^0.5.8", "@types/jest": "^29.5.14", "@types/node": "^20.19.39", "eslint": "^9.39.5", "eslint-config-universe": "^16.0.0", + "i18next": "^26.4.2", "jest": "^29.7.0", "jest-expo": "^55.0.17", + "newrelic-react-native-agent": "^1.9.0", "prettier": "^3.9.6", + "react-i18next": "^17.0.13", "react-test-renderer": "^19.2.5" }, "peerDependencies": { @@ -30,6 +35,76 @@ "react-native": "*" } }, + "node_modules/@amplitude/analytics-connector": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-connector/-/analytics-connector-1.6.8.tgz", + "integrity": "sha512-ryZUsakytEZZ+g/O5mTsObckhCKtZrNdZw+KKcbuzVCZa1LJFPWHvKj+jwQpPO0ngsbb6oEpC5laaIEM5B4XKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@amplitude/analytics-core": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.55.0.tgz", + "integrity": "sha512-fzjXEWX2o5I58Tl5jYaw2G5Tp5ejfcDkayWMogEThCl/fd6AqRKkrl/dVVqGV3h/GS3HZws7J4baIWkaq92jkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@amplitude/analytics-connector": "^1.6.4", + "@types/zen-observable": "0.8.3", + "safe-json-stringify": "1.2.0", + "tslib": "^2.4.1", + "zen-observable": "0.10.0" + } + }, + "node_modules/@amplitude/analytics-react-native": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-react-native/-/analytics-react-native-1.8.0.tgz", + "integrity": "sha512-kPMXimpbdw+DPPdSNFgpZGQ7tJccDfu//mmwqyZErC3VIKTryLh4uNdqVzKc9TNBxS6798pP7xvKlyVs7ARF1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.55.0", + "@amplitude/plugin-network-capture-browser": "1.10.14", + "@amplitude/ua-parser-js": "^0.7.31", + "@react-native-async-storage/async-storage": "^1.17.11", + "tslib": "^2.4.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@amplitude/plugin-network-capture-browser": { + "version": "1.10.14", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-network-capture-browser/-/plugin-network-capture-browser-1.10.14.tgz", + "integrity": "sha512-MGRgT89KaPDvrZXMp+5rWgF5GTC7YkCj/gIgHZ22FgH1CrejwHhA5FWTurW28f48rOgZ+V1Ruqab70/sst4KJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.55.0", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/ua-parser-js": { + "version": "0.7.33", + "resolved": "https://registry.npmjs.org/@amplitude/ua-parser-js/-/ua-parser-js-0.7.33.tgz", + "integrity": "sha512-wKEtVR4vXuPT9cVEIJkYWnlF++Gx3BdLatPBM+SZ1ztVIvnhdGBZR/mn9x/PzyrMcRlZmyi6L56I2J3doVBnjA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -403,6 +478,100 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/highlight": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz", + "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/parser": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", @@ -1450,11 +1619,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -2309,6 +2477,109 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@isaacs/ttlcache": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", @@ -2838,6 +3109,55 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@pkgr/core": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", @@ -2851,44 +3171,284 @@ "url": "https://opencollective.com/pkgr" } }, - "node_modules/@react-native/assets-registry": { - "version": "0.85.2", - "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.85.2.tgz", - "integrity": "sha512-kauC/oPaxklU4Y+u9gBfCBJm51qX6WBZq4xx0USCdimtp+G8+554kpygfSWIjoqCJa2o06bWxBEjesiuCv+LzA==", + "node_modules/@react-native-async-storage/async-storage": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.24.0.tgz", + "integrity": "sha512-W4/vbwUOYOjco0x3toB8QCr7EjIP6nE9G7o8PMguvvjYT5Awg09lyV4enACRx4s++PPulBiBSjL0KTFx2u0Z/g==", + "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.60 <1.0" } }, - "node_modules/@react-native/babel-plugin-codegen": { - "version": "0.83.6", - "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.83.6.tgz", - "integrity": "sha512-qfRXsHGeucT5c6mK+8Q7v4Ly3zmygfVmFlEtkiq7q07W1OTreld6nib4rJ/DBEeNiKBoBTuHjWliYGNuDjLFQA==", + "node_modules/@react-native-community/cli-platform-android": { + "version": "13.6.9", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-android/-/cli-platform-android-13.6.9.tgz", + "integrity": "sha512-9KsYGdr08QhdvT3Ht7e8phQB3gDX9Fs427NJe0xnoBh+PDPTI2BD5ks5ttsH8CzEw8/P6H8tJCHq6hf2nxd9cw==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/traverse": "^7.25.3", - "@react-native/codegen": "0.83.6" - }, - "engines": { - "node": ">= 20.19.4" + "@react-native-community/cli-tools": "13.6.9", + "chalk": "^4.1.2", + "execa": "^5.0.0", + "fast-glob": "^3.3.2", + "fast-xml-parser": "^4.2.4", + "logkitty": "^0.7.1" } }, - "node_modules/@react-native/babel-preset": { - "version": "0.83.6", - "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.83.6.tgz", - "integrity": "sha512-4/fXFDUvGOObETZq4+SUFkafld6OGgQWut5cQiqVghlhCB5z/p2lVhPgEUr/aTxTzeS3AmN+ztC+GpYPQ7tsTw==", + "node_modules/@react-native-community/cli-tools": { + "version": "13.6.9", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-tools/-/cli-tools-13.6.9.tgz", + "integrity": "sha512-OXaSjoN0mZVw3nrAwcY1PC0uMfyTd9fz7Cy06dh+EJc+h0wikABsVRzV8cIOPrVV+PPEEXE0DBrH20T2puZzgQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/core": "^7.25.2", - "@babel/plugin-proposal-export-default-from": "^7.24.7", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-syntax-export-default-from": "^7.24.7", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-transform-arrow-functions": "^7.24.7", + "appdirsjs": "^1.2.4", + "chalk": "^4.1.2", + "execa": "^5.0.0", + "find-up": "^5.0.0", + "mime": "^2.4.1", + "node-fetch": "^2.6.0", + "open": "^6.2.0", + "ora": "^5.4.1", + "semver": "^7.5.2", + "shell-quote": "^1.7.3", + "sudo-prompt": "^9.0.0" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/is-wsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/open": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/open/-/open-6.4.0.tgz", + "integrity": "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-native/assets-registry": { + "version": "0.85.2", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.85.2.tgz", + "integrity": "sha512-kauC/oPaxklU4Y+u9gBfCBJm51qX6WBZq4xx0USCdimtp+G8+554kpygfSWIjoqCJa2o06bWxBEjesiuCv+LzA==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/babel-plugin-codegen": { + "version": "0.83.6", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.83.6.tgz", + "integrity": "sha512-qfRXsHGeucT5c6mK+8Q7v4Ly3zmygfVmFlEtkiq7q07W1OTreld6nib4rJ/DBEeNiKBoBTuHjWliYGNuDjLFQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/traverse": "^7.25.3", + "@react-native/codegen": "0.83.6" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/babel-preset": { + "version": "0.83.6", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.83.6.tgz", + "integrity": "sha512-4/fXFDUvGOObETZq4+SUFkafld6OGgQWut5cQiqVghlhCB5z/p2lVhPgEUr/aTxTzeS3AmN+ztC+GpYPQ7tsTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.24.7", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", @@ -3584,75 +4144,702 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "license": "MIT", - "peer": true, + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@react-native/gradle-plugin": { + "version": "0.85.2", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.85.2.tgz", + "integrity": "sha512-YXBOLeAqFrv7XwUeBPTKZeOV1FIxn4AW7UAEitScf3ibC8bu8+6NpJu4HWgbNQHg7vDbbTZVbcOl8EwGxsSq2w==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/jest-preset": { + "version": "0.85.2", + "resolved": "https://registry.npmjs.org/@react-native/jest-preset/-/jest-preset-0.85.2.tgz", + "integrity": "sha512-tCps+2P67PKbFMlqlLMmYuvQ3C7QFAWaMvax/SfBktZO4TydFVxwGgXoVC+db35uY8Bue6xrkcNOwrFjY8ewzw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^29.7.0", + "@react-native/js-polyfills": "0.85.2", + "babel-jest": "^29.7.0", + "jest-environment-node": "^29.7.0", + "regenerator-runtime": "^0.13.2" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/@react-native/js-polyfills": { + "version": "0.85.2", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.2.tgz", + "integrity": "sha512-esGEAmKVM40DV/yVmNljCKZTIeUo7qXqc+Hwffkv3TG+b3E24xyFovHrbP98gGxZr2ZsEyx+2sKLdXF5asY5nw==", + "license": "MIT", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.83.6", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.83.6.tgz", + "integrity": "sha512-bTM24b5v4qN3h52oflnv+OujFORn/kVi06WaWhnQQw14/ycilPqIsqsa+DpIBqdBrXxvLa9fXtCRrQtGATZCEw==", + "license": "MIT", + "peer": true + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sentry/browser": { + "version": "10.74.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.74.0.tgz", + "integrity": "sha512-U6Zn+YcAgnR7bXZmpjMxivptPSsOeDKV+SlSqbJZqViOaCcZSG/vJiQr50UrdTccCwiU1LnwuOOoclJOvW+cLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.74.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.74.0", + "@sentry/feedback": "10.74.0", + "@sentry/replay": "10.74.0", + "@sentry/replay-canvas": "10.74.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser-utils": { + "version": "10.74.0", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.74.0.tgz", + "integrity": "sha512-/Rh3WH4i2nStdOqHpaWocbpSMFPjKhlxKQgp9vkJBSolqjJzXlIpLYXOrldZHHZLG1iDoX3PFBQkNDYcPXCg0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.74.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/bundler-plugins": { + "version": "10.74.0", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-10.74.0.tgz", + "integrity": "sha512-BEBMIlfABB0mS22/KAQXTYSpL+HXucsvvPW421hsIlh6IlBft1yx7wfrjl4uHu1BD54etOrVyZIoUr7AnXzP+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.18.5", + "@sentry/cli": "^2.58.6", + "@sentry/core": "10.74.0", + "dotenv": "^17.4.2", + "find-up": "^5.0.0", + "glob": "^13.0.6", + "magic-string": "~0.30.8" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "rollup": ">=3.2.0", + "webpack": ">=5.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz", + "integrity": "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==", + "dev": true, + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.58.6", + "@sentry/cli-linux-arm": "2.58.6", + "@sentry/cli-linux-arm64": "2.58.6", + "@sentry/cli-linux-i686": "2.58.6", + "@sentry/cli-linux-x64": "2.58.6", + "@sentry/cli-win32-arm64": "2.58.6", + "@sentry/cli-win32-i686": "2.58.6", + "@sentry/cli-win32-x64": "2.58.6" + } + }, + "node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli-darwin": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz", + "integrity": "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==", + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli-linux-arm": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz", + "integrity": "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli-linux-arm64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz", + "integrity": "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli-linux-i686": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz", + "integrity": "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli-linux-x64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz", + "integrity": "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli-win32-arm64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz", + "integrity": "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli-win32-i686": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz", + "integrity": "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/bundler-plugins/node_modules/@sentry/cli-win32-x64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz", + "integrity": "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/bundler-plugins/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@sentry/bundler-plugins/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sentry/bundler-plugins/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@sentry/bundler-plugins/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sentry/bundler-plugins/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sentry/cli": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.7.0.tgz", + "integrity": "sha512-UV6/hUaEL7X1ATx5RHPfDEObh+nTRwOeUkNwF6R+HDo6iAki063GiLOmkfroemkVD0L4370SSvGtc5+Y7MZkhQ==", + "dev": true, + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "undici": "^6.22.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "3.7.0", + "@sentry/cli-linux-arm": "3.7.0", + "@sentry/cli-linux-arm64": "3.7.0", + "@sentry/cli-linux-i686": "3.7.0", + "@sentry/cli-linux-x64": "3.7.0", + "@sentry/cli-win32-arm64": "3.7.0", + "@sentry/cli-win32-i686": "3.7.0", + "@sentry/cli-win32-x64": "3.7.0" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.7.0.tgz", + "integrity": "sha512-Kb6Oem+6lfxkWSH8iz8DHwYmbMtYxFoTk3VHfeTkpz60TRj+0OPga/0ZEsCCgG/Df17E7z2kbkoe5Ld+dEj3Tw==", + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.7.0.tgz", + "integrity": "sha512-PKEFwwRCOG92u2dXusLbRK4cZ7XXlaUY8TkhaZy/PJ/FNsST7Sh+MbdWQCgPB0FsvZrgImcKIEyCJZtc0GunJg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.7.0.tgz", + "integrity": "sha512-m6VlsDJBo9/9ufASv4m4heKS0zfgUJdcSvKgIQerZaLwKOfhY8OP7zhlJPkFmnFjOjyvNyaqzoR00EnlprdwFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.7.0.tgz", + "integrity": "sha512-6NeOPqsPuyrOgVGPFunPxgqHCes+a8r6pbCgq9+qSD3oa5fk9H0KOKB0sIC0YyH43OGzdzmwn0XNbdF6jwJO6A==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.7.0.tgz", + "integrity": "sha512-74R9fuAldhC38Aoco8XQWTBvs2T2NatrDvI/ISHihej2oSnbQmL7krJF3yWn33MchQWrsFKgg7mboqemJnreZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.7.0.tgz", + "integrity": "sha512-xhmTUW3N3m8iZ4mkV8SrNOr5UN8+6zpvlvJxCK6Z15Tp8S14N1T+hfKeyL2xpLxBcd7Q7oW1dYXUl5b7BHNqjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.7.0.tgz", + "integrity": "sha512-/CvFzrCWUB0URWKYQeoNs8UbPJcBz6KD/AX27reyQxPd8S3AnTwcaYfCf8SRHEH9Iu+1IKzi5tUUNUB18b7ijg==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.7.0.tgz", + "integrity": "sha512-PNW1O6sZw3JXhhJFgOiW7isFHX/qbniIDxswM7kb0wF2aVUpLXLltbezchWwTAxIRXo9gcu/oMTK0rntdqL5Eg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/conventions": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", + "integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/core": { + "version": "10.74.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.74.0.tgz", + "integrity": "sha512-u9rY8vcZfktccwm6LznfCZlqP5C9A+p76r4/pFS1grqpuTO0m21Cl8rosnlESrDGP/Xd9tfr91rWYk0jPH8jeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/expo-upload-sourcemaps": { + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@sentry/expo-upload-sourcemaps/-/expo-upload-sourcemaps-8.26.0.tgz", + "integrity": "sha512-tXI7FUDPIT8lKmuUqe2U8MT9Dic3tjgVs98yUuaZQzC/e0dzm8vU01skmyQN3sOmZ8CgoPLovlKbXajIBZy1Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/cli": "3.7.0" + }, + "bin": { + "expo-upload-sourcemaps": "cli.js" + }, "engines": { - "node": ">=8.3.0" + "node": ">=18" }, "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "@expo/env": "*", + "dotenv": "*" }, "peerDependenciesMeta": { - "bufferutil": { + "@expo/env": { "optional": true }, - "utf-8-validate": { + "dotenv": { "optional": true } } }, - "node_modules/@react-native/gradle-plugin": { - "version": "0.85.2", - "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.85.2.tgz", - "integrity": "sha512-YXBOLeAqFrv7XwUeBPTKZeOV1FIxn4AW7UAEitScf3ibC8bu8+6NpJu4HWgbNQHg7vDbbTZVbcOl8EwGxsSq2w==", + "node_modules/@sentry/feedback": { + "version": "10.74.0", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.74.0.tgz", + "integrity": "sha512-WwUao9K2XgRIxTUTaBhm7aglhsyP7LQTGi2lxvFbXzlSagUzwew/EL2c5oqQ23Mjn94i5SY+rM4wMdgyWdtpAw==", + "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "@sentry/core": "10.74.0" + }, "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + "node": ">=18" } }, - "node_modules/@react-native/jest-preset": { - "version": "0.85.2", - "resolved": "https://registry.npmjs.org/@react-native/jest-preset/-/jest-preset-0.85.2.tgz", - "integrity": "sha512-tCps+2P67PKbFMlqlLMmYuvQ3C7QFAWaMvax/SfBktZO4TydFVxwGgXoVC+db35uY8Bue6xrkcNOwrFjY8ewzw==", - "devOptional": true, + "node_modules/@sentry/react": { + "version": "10.74.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.74.0.tgz", + "integrity": "sha512-eKSNCbLyvc/qk4rIrm1IQZnC1zYPMJBqOVTX4VQCbK+d6YnzLc8JuZsw3PaGeB2x1g3CGe4qtBd9HUvsvBD9aw==", + "dev": true, "license": "MIT", "dependencies": { - "@jest/create-cache-key-function": "^29.7.0", - "@react-native/js-polyfills": "0.85.2", - "babel-jest": "^29.7.0", - "jest-environment-node": "^29.7.0", - "regenerator-runtime": "^0.13.2" + "@sentry/browser": "10.74.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.74.0" }, "engines": { - "node": ">= 20.19.4" + "node": ">=18" }, "peerDependencies": { - "react": "^19.2.3" + "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, - "node_modules/@react-native/js-polyfills": { - "version": "0.85.2", - "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.2.tgz", - "integrity": "sha512-esGEAmKVM40DV/yVmNljCKZTIeUo7qXqc+Hwffkv3TG+b3E24xyFovHrbP98gGxZr2ZsEyx+2sKLdXF5asY5nw==", + "node_modules/@sentry/react-native": { + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-8.26.0.tgz", + "integrity": "sha512-Xg3hNRnTgC2mLB2fNLp7fqVGz5h/8MXPch2NREHZEiR4Tg3kFxbMkpSNGBrb/HQ6P14qgKRlOkews+XckzicyA==", + "dev": true, "license": "MIT", - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + "dependencies": { + "@sentry/browser": "10.74.0", + "@sentry/bundler-plugins": "10.74.0", + "@sentry/cli": "3.7.0", + "@sentry/core": "10.74.0", + "@sentry/expo-upload-sourcemaps": "8.26.0", + "@sentry/react": "10.74.0" + }, + "bin": { + "sentry-eas-build-on-complete": "scripts/eas-build-hook.js", + "sentry-eas-build-on-error": "scripts/eas-build-hook.js", + "sentry-eas-build-on-success": "scripts/eas-build-hook.js", + "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js" + }, + "peerDependencies": { + "expo": ">=49.0.0", + "react": ">=17.0.0", + "react-native": ">=0.65.0" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } } }, - "node_modules/@react-native/normalize-colors": { - "version": "0.83.6", - "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.83.6.tgz", - "integrity": "sha512-bTM24b5v4qN3h52oflnv+OujFORn/kVi06WaWhnQQw14/ycilPqIsqsa+DpIBqdBrXxvLa9fXtCRrQtGATZCEw==", + "node_modules/@sentry/replay": { + "version": "10.74.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.74.0.tgz", + "integrity": "sha512-amIu0zKzhQJZoWmlwfFnn/uKRGyF+Tikph3lBgYwj5XbHv4U94PQXi9Ll8CYzzLVS7BkrCVc2Hl7RvpRg5M2Yg==", + "dev": true, "license": "MIT", - "peer": true + "dependencies": { + "@sentry/browser-utils": "10.74.0", + "@sentry/core": "10.74.0" + }, + "engines": { + "node": ">=18" + } }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "node_modules/@sentry/replay-canvas": { + "version": "10.74.0", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.74.0.tgz", + "integrity": "sha512-HAdu4ViRsuOD5ifphQR24HugL0geZXV8LnGQVkA8vlzZWiuYgGNqDRoUWI75IgVcCYLYSaVBoKN+jTimdzG/sQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@sentry/core": "10.74.0", + "@sentry/replay": "10.74.0" + }, + "engines": { + "node": ">=18" + } }, "node_modules/@sinclair/typebox": { "version": "0.27.10", @@ -3947,6 +5134,13 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "license": "MIT" }, + "node_modules/@types/zen-observable": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.3.tgz", + "integrity": "sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", @@ -4357,6 +5551,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ansi-fragments": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/ansi-fragments/-/ansi-fragments-0.2.1.tgz", + "integrity": "sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^1.0.7", + "slice-ansi": "^2.0.0", + "strip-ansi": "^5.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -4408,6 +5614,23 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/appdirsjs": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/appdirsjs/-/appdirsjs-1.2.8.tgz", + "integrity": "sha512-8zl1xlxeS4a0/36CT6LOaVioPOL8TeLT1b9OHk0j9xSbzmPBuM7lUgWMSTh6SbuF8fbwjcP1rr30OCLpd1fl+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/codingjerk" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/codingjerk" + } + ], + "license": "MIT" + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -4588,6 +5811,16 @@ "license": "MIT", "peer": true }, + "node_modules/astral-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", + "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -4966,6 +6199,18 @@ "node": ">=0.6" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -5054,6 +6299,31 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5262,7 +6532,6 @@ "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" }, @@ -5301,7 +6570,6 @@ "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.8" } @@ -5342,6 +6610,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -5623,6 +6898,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5640,6 +6922,16 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -5683,7 +6975,6 @@ "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", "license": "MIT", - "peer": true, "dependencies": { "clone": "^1.0.2" }, @@ -5832,6 +7123,19 @@ "node": ">=12" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5847,6 +7151,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7325,6 +8636,36 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -7339,6 +8680,35 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.7.tgz", + "integrity": "sha512-a6Qh1RMCNbSrU1+sAyAAZH3rTe+OaWJbNZIq0S+ifZciUUOQtlVxBJwoTUE2bYhysmG/RYyI5WJFIKdBahJdrQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fb-dotslash": { "version": "0.5.8", "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", @@ -7511,6 +8881,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -7548,7 +8948,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -7974,6 +9373,16 @@ "dev": true, "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -8057,6 +9466,35 @@ "node": ">=10.17.0" } }, + "node_modules/i18next": { + "version": "26.4.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz", + "integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -8070,6 +9508,27 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -8455,6 +9914,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -8507,6 +9976,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -8626,6 +10105,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -8797,6 +10289,22 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -10224,6 +11732,13 @@ "license": "MIT", "peer": true }, + "node_modules/lodash.foreach": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz", + "integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -10329,6 +11844,115 @@ "node": ">=4" } }, + "node_modules/logkitty": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/logkitty/-/logkitty-0.7.1.tgz", + "integrity": "sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-fragments": "^0.2.1", + "dayjs": "^1.8.15", + "yargs": "^15.1.0" + }, + "bin": { + "logkitty": "bin/logkitty.js" + } + }, + "node_modules/logkitty/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/logkitty/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/logkitty/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/logkitty/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -10350,6 +11974,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -10399,12 +12033,35 @@ "license": "MIT", "peer": true }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "license": "MIT" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/metro": { "version": "0.83.6", "resolved": "https://registry.npmjs.org/metro/-/metro-0.83.6.tgz", @@ -10977,6 +12634,164 @@ "node": ">= 0.6" } }, + "node_modules/newrelic-react-native-agent": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/newrelic-react-native-agent/-/newrelic-react-native-agent-1.9.0.tgz", + "integrity": "sha512-aNwYTdhKIwnX4LLOk15wS2uoN5tvGummgxCxUGstW3ofBhopKTErQvvugpoVqxaB7joJWREMTqgPUFs2K098sQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@expo/config-plugins": "^10.1.1", + "@react-native-community/cli-platform-android": "^13.6.4", + "lodash.foreach": "^4.5.0", + "react-native-promise-rejection-utils": "0.0.1" + }, + "peerDependencies": { + "react-native": "*" + } + }, + "node_modules/newrelic-react-native-agent/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/newrelic-react-native-agent/node_modules/@expo/config-plugins": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-10.1.2.tgz", + "integrity": "sha512-IMYCxBOcnuFStuK0Ay+FzEIBKrwW8OVUMc65+v0+i7YFIIe8aL342l7T4F8lR4oCfhXn7d6M5QPgXvjtc/gAcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@expo/config-types": "^53.0.5", + "@expo/json-file": "~9.1.5", + "@expo/plist": "^0.3.5", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^10.4.2", + "resolve-from": "^5.0.0", + "semver": "^7.5.4", + "slash": "^3.0.0", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/newrelic-react-native-agent/node_modules/@expo/config-types": { + "version": "53.0.5", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-53.0.5.tgz", + "integrity": "sha512-kqZ0w44E+HEGBjy+Lpyn0BVL5UANg/tmNixxaRMLS6nf37YsDrLk2VMAmeKMMk5CKG0NmOdVv3ngeUjRQMsy9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/newrelic-react-native-agent/node_modules/@expo/json-file": { + "version": "9.1.5", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-9.1.5.tgz", + "integrity": "sha512-prWBhLUlmcQtvN6Y7BpW2k9zXGd3ySa3R6rAguMJkp1z22nunLN64KYTUWfijFlprFoxm9r2VNnGkcbndAlgKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.3" + } + }, + "node_modules/newrelic-react-native-agent/node_modules/@expo/plist": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.3.5.tgz", + "integrity": "sha512-9RYVU1iGyCJ7vWfg3e7c/NVyMFs8wbl+dMWZphtFtsqyN9zppGREU3ctlD3i8KUE0sCUTVnLjCWr+VeUIDep2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.2.3", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/newrelic-react-native-agent/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/newrelic-react-native-agent/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/newrelic-react-native-agent/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/newrelic-react-native-agent/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/newrelic-react-native-agent/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/newrelic-react-native-agent/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/node-exports-info": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", @@ -11006,6 +12821,52 @@ "semver": "bin/semver.js" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-forge": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", @@ -11467,6 +13328,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -11787,7 +13655,6 @@ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.4.0" } @@ -11834,6 +13701,13 @@ "dev": true, "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -11891,6 +13765,27 @@ "inherits": "~2.0.3" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -11944,6 +13839,34 @@ } } }, + "node_modules/react-i18next": { + "version": "17.0.13", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.13.tgz", + "integrity": "sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -12010,6 +13933,17 @@ } } }, + "node_modules/react-native-promise-rejection-utils": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/react-native-promise-rejection-utils/-/react-native-promise-rejection-utils-0.0.1.tgz", + "integrity": "sha512-ToQv5u8zzYf+c1TTBD18UwDozSWGkekSPJ44xovsWZMF5I21DzEbO3P9UStjMWiFuI24CZiRbOoZHlvgjMtP+A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native/node_modules/@react-native/codegen": { "version": "0.85.2", "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.2.tgz", @@ -12222,6 +14156,21 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -12366,6 +14315,13 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true, + "license": "ISC" + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -12456,6 +14412,17 @@ "node": ">=4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -12526,6 +14493,30 @@ "node": "*" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -12564,8 +14555,14 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "peer": true + "license": "MIT" + }, + "node_modules/safe-json-stringify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz", + "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", + "dev": true, + "license": "MIT" }, "node_modules/safe-push-apply": { "version": "1.0.0", @@ -12767,6 +14764,13 @@ "dev": true, "license": "MIT" }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -12849,7 +14853,6 @@ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -12978,6 +14981,61 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", + "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.0", + "astral-regex": "^1.0.0", + "is-fullwidth-code-point": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/slice-ansi/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/slice-ansi/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/slugify": { "version": "1.6.9", "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", @@ -13153,6 +15211,16 @@ "node": ">= 0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -13194,6 +15262,35 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string-width/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -13310,7 +15407,6 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^4.1.0" }, @@ -13318,12 +15414,25 @@ "node": ">=6" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi/node_modules/ansi-regex": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -13374,6 +15483,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/structured-headers": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", @@ -13381,6 +15503,14 @@ "license": "MIT", "peer": true }, + "node_modules/sudo-prompt": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz", + "integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -13726,6 +15856,13 @@ "node": ">=4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -13871,6 +16008,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -13992,6 +16139,23 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -14080,7 +16244,6 @@ "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", "license": "MIT", - "peer": true, "dependencies": { "defaults": "^1.0.3" } @@ -14229,6 +16392,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true, + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.22", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", @@ -14278,6 +16448,38 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -14463,6 +16665,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zen-observable": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.10.0.tgz", + "integrity": "sha512-iI3lT0iojZhKwT5DaFy2Ce42n3yFcLdFyOh01G7H0flMY60P8MJuVFEoJoNwXlmAyQ45GrjL6AcZmmlv8A5rbw==", + "dev": true, + "license": "MIT" + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index d6e2bc3..4fa5140 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "scripts": { "build": "tsc -p tsconfig.build.json && tsc -p plugin/tsconfig.build.json", "lint": "eslint . --max-warnings 0", + "typecheck:examples": "tsc -p tsconfig.examples.json && node scripts/check-doc-examples.js", "test": "jest", "test:coverage": "jest --coverage", "prepublishOnly": "npm run build && npm test" @@ -41,16 +42,21 @@ "license": "MIT", "homepage": "https://github.com/IronTony/react-native-docusign", "devDependencies": { + "@amplitude/analytics-react-native": "^1.8.0", "@react-native/jest-preset": "^0.85.2", + "@sentry/react-native": "^8.26.0", "@testing-library/react-native": "^13.3.3", "@types/adm-zip": "^0.5.8", "@types/jest": "^29.5.14", "@types/node": "^20.19.39", "eslint": "^9.39.5", "eslint-config-universe": "^16.0.0", + "i18next": "^26.4.2", "jest": "^29.7.0", "jest-expo": "^55.0.17", + "newrelic-react-native-agent": "^1.9.0", "prettier": "^3.9.6", + "react-i18next": "^17.0.13", "react-test-renderer": "^19.2.5" }, "peerDependencies": { diff --git a/scripts/check-doc-examples.js b/scripts/check-doc-examples.js new file mode 100644 index 0000000..679af42 --- /dev/null +++ b/scripts/check-doc-examples.js @@ -0,0 +1,51 @@ +// Fails when docs/ERROR_HANDLING.md and examples/error-handling drift apart. +// The examples are type-checked; this keeps the copies in the guide identical +// to them, so the published documentation is the code CI compiled. +const fs = require('fs'); +const path = require('path'); + +// npm scripts run from the package root. +const root = process.cwd(); +const guidePath = path.join(root, 'docs', 'ERROR_HANDLING.md'); +const examplesDir = path.join(root, 'examples', 'error-handling'); + +const guide = fs.readFileSync(guidePath, 'utf8'); +const blockPattern = /\n```\w*\n([\s\S]*?)\n```/g; + +function listExampleFiles(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const fullPath = path.join(dir, entry.name); + return entry.isDirectory() ? listExampleFiles(fullPath) : [fullPath]; + }); +} + +const problems = []; +const embedded = new Set(); + +for (const [, relativePath, block] of guide.matchAll(blockPattern)) { + embedded.add(relativePath); + const filePath = path.join(root, relativePath); + if (!fs.existsSync(filePath)) { + problems.push( + `${relativePath} is embedded in the guide but does not exist`, + ); + continue; + } + if (fs.readFileSync(filePath, 'utf8').trimEnd() !== block.trimEnd()) { + problems.push(`${relativePath} differs from its copy in the guide`); + } +} + +for (const filePath of listExampleFiles(examplesDir)) { + const relativePath = path.relative(root, filePath).split(path.sep).join('/'); + if (!embedded.has(relativePath)) { + problems.push(`${relativePath} is not embedded in the guide`); + } +} + +if (problems.length > 0) { + console.error(problems.join('\n')); + process.exit(1); +} + +console.log(`docs/ERROR_HANDLING.md matches ${embedded.size} example files`); diff --git a/src/DocuSign.types.ts b/src/DocuSign.types.ts index 51e5557..65f3def 100644 --- a/src/DocuSign.types.ts +++ b/src/DocuSign.types.ts @@ -66,12 +66,18 @@ export type CaptiveSigningUrlParams = { recipientId?: string; }; +/** + * `'error'` is never returned since 2.0.0: every failure rejects with a + * `DocuSignError` instead. It stays in the union so existing `switch` + * statements keep compiling. + */ export type SigningStatus = 'completed' | 'cancelled' | 'error'; export type SigningResult = { status: SigningStatus; envelopeId: string; errorCode?: string; + /** For `cancelled`, the SDK's exit reason when it provides one. */ errorMessage?: string; }; @@ -84,10 +90,25 @@ export type SigningCancelledEvent = { reason?: string; }; +/** + * The raw failure payload native code sends, both as the resolved value of a + * failed call and as the `onSigningError` event on `DocuSignModule`. Apps should + * not need it: the functions exported by this package turn it into a + * `DocuSignError`, which is what `addSigningErrorListener` delivers. + */ export type SigningErrorEvent = { envelopeId?: string; errorCode: string; errorMessage: string; + nativeDomain?: string; + nativeCode?: string; + nativeMessage?: string; + underlyingDomain?: string; + underlyingCode?: string; + underlyingMessage?: string; + httpStatus?: number; + docusignErrorCode?: string; + docusignMessage?: string; }; export type LoginAttemptEvent = { diff --git a/src/DocuSignError.test.ts b/src/DocuSignError.test.ts new file mode 100644 index 0000000..e91bf56 --- /dev/null +++ b/src/DocuSignError.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it } from '@jest/globals'; + +import { + DocuSignError, + deriveReason, + fromFailurePayload, + redactSecrets, + toDocuSignError, +} from './DocuSignError'; + +describe('deriveReason', () => { + it.each([ + 'not_initialized', + 'not_logged_in', + 'signing_in_progress', + 'invalid_signing_url', + 'presentation_failed', + ] as const)('classifies %s as usage', (code) => { + expect(deriveReason({ code })).toBe('usage'); + }); + + it('classifies an iOS URL loading error as network', () => { + expect( + deriveReason({ + code: 'signing_failed', + native: { domain: 'NSURLErrorDomain', code: '-1005' }, + }), + ).toBe('network'); + }); + + it('classifies a network error found in the underlying error as network', () => { + expect( + deriveReason({ + code: 'signing_failed', + native: { + domain: 'com.docusign.androidsdk.exceptions.DSSigningException', + underlying: { domain: 'java.net.UnknownHostException' }, + }, + }), + ).toBe('network'); + }); + + it.each([ + 'java.net.UnknownHostException', + 'java.net.SocketTimeoutException', + 'java.net.ConnectException', + ])('classifies Android %s as network', (domain) => { + expect(deriveReason({ code: 'signing_failed', native: { domain } })).toBe( + 'network', + ); + }); + + it('prefers network over an HTTP status seen earlier in the flow', () => { + expect( + deriveReason({ + code: 'signing_failed', + native: { domain: 'NSURLErrorDomain' }, + http: { status: 401 }, + }), + ).toBe('network'); + }); + + it.each([401, 403])('classifies HTTP %i as auth', (status) => { + expect(deriveReason({ code: 'login_failed', http: { status } })).toBe( + 'auth', + ); + }); + + it('classifies a login rejected despite a valid token as configuration', () => { + expect(deriveReason({ code: 'login_failed', http: { status: 200 } })).toBe( + 'configuration', + ); + }); + + it('does not call a successful HTTP status configuration outside login', () => { + expect( + deriveReason({ code: 'signing_failed', http: { status: 200 } }), + ).toBe('unknown'); + }); + + it('classifies an unknown envelope recipient as recipient', () => { + expect( + deriveReason({ + code: 'signing_failed', + http: { status: 400, docusignErrorCode: 'UNKNOWN_ENVELOPE_RECIPIENT' }, + }), + ).toBe('recipient'); + }); + + it('falls back to unknown when no fact identifies the cause', () => { + expect( + deriveReason({ + code: 'signing_failed', + native: { domain: 'DocuSignSDK', code: '1001' }, + http: { status: 500, docusignErrorCode: 'SOMETHING_ELSE' }, + }), + ).toBe('unknown'); + }); +}); + +describe('redactSecrets', () => { + it('redacts a JWT', () => { + expect( + redactSecrets( + 'token eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjMifQ.c2lnbmF0dXJl was rejected', + ), + ).toBe('token [redacted-token] was rejected'); + }); + + it('redacts a bearer credential', () => { + expect(redactSecrets('Authorization: Bearer abc.def.ghi')).toBe( + 'Authorization: Bearer [redacted]', + ); + }); + + it('keeps the origin and short path of a URL but drops the query and token-like segments', () => { + expect( + redactSecrets( + 'failed to load https://demo.docusign.net/Signing/MTRedeem/v1/4b5c7d9e-1f2a-4b3c-8d9e-0f1a2b3c4d5e?slt=abc', + ), + ).toBe( + 'failed to load https://demo.docusign.net/Signing/MTRedeem/v1/[redacted]', + ); + }); + + it.each([ + ['a period', '.'], + ['a comma', ','], + ['a closing parenthesis', ')'], + ['several punctuation marks', ').'], + ])( + 'redacts a token segment followed by %s and keeps the punctuation', + (_label, punctuation) => { + expect( + redactSecrets( + `blocked by https://account.docusign.com/o/abcdefghijklmnopqrstuvwxyz0123456789${punctuation} Try again`, + ), + ).toBe( + `blocked by https://account.docusign.com/o/[redacted]${punctuation} Try again`, + ); + }, + ); + + it('drops a query that is followed by punctuation', () => { + expect( + redactSecrets('open https://demo.docusign.net/Signing?slt=abc123.'), + ).toBe('open https://demo.docusign.net/Signing.'); + }); + + it('keeps punctuation after a URL that has no path', () => { + expect(redactSecrets('host was https://demo.docusign.net.')).toBe( + 'host was https://demo.docusign.net.', + ); + }); + + it('leaves a plain REST root readable', () => { + expect(redactSecrets('host=https://demo.docusign.net/restapi')).toBe( + 'host=https://demo.docusign.net/restapi', + ); + }); + + it('leaves text without secrets unchanged', () => { + expect(redactSecrets('The network connection was lost.')).toBe( + 'The network connection was lost.', + ); + }); +}); + +describe('DocuSignError', () => { + it('is an Error with a stable name', () => { + const error = new DocuSignError({ + code: 'signing_failed', + message: 'boom', + }); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(DocuSignError); + expect(error.name).toBe('DocuSignError'); + expect(error.message).toBe('boom'); + }); + + it('redacts secrets in every message field', () => { + const error = new DocuSignError({ + code: 'signing_failed', + message: 'Bearer secret-token', + native: { + message: 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjMifQ.c2ln', + underlying: { + message: 'https://demo.docusign.net/x?token=abc', + }, + }, + http: { status: 400, docusignMessage: 'Bearer another' }, + }); + + expect(error.message).toBe('Bearer [redacted]'); + expect(error.native?.message).toBe('[redacted-token]'); + expect(error.native?.underlying?.message).toBe( + 'https://demo.docusign.net/x', + ); + expect(error.http?.docusignMessage).toBe('Bearer [redacted]'); + }); + + it('derives its reason from the facts it was built with', () => { + const error = new DocuSignError({ + code: 'login_failed', + message: 'rejected', + http: { status: 401 }, + }); + + expect(error.reason).toBe('auth'); + }); + + it('flattens every known fact into attributes', () => { + const error = new DocuSignError({ + code: 'signing_failed', + message: 'failed', + envelopeId: 'env-1', + native: { + domain: 'NSURLErrorDomain', + code: '-1005', + message: 'lost', + underlying: { domain: 'kCFErrorDomainCFNetwork', code: '-1005' }, + }, + http: { status: 400, docusignErrorCode: 'UNKNOWN_ENVELOPE_RECIPIENT' }, + }); + + expect(error.toAttributes()).toEqual({ + docusign_code: 'signing_failed', + docusign_reason: 'network', + docusign_envelope_id: 'env-1', + docusign_native_domain: 'NSURLErrorDomain', + docusign_native_code: '-1005', + docusign_underlying_domain: 'kCFErrorDomainCFNetwork', + docusign_underlying_code: '-1005', + docusign_http_status: 400, + docusign_api_error_code: 'UNKNOWN_ENVELOPE_RECIPIENT', + }); + }); + + it('omits attributes it has no value for', () => { + const error = new DocuSignError({ + code: 'not_initialized', + message: 'init', + }); + + expect(error.toAttributes()).toEqual({ + docusign_code: 'not_initialized', + docusign_reason: 'usage', + }); + }); +}); + +describe('fromFailurePayload', () => { + it('builds native and http details from the flat native payload', () => { + const error = fromFailurePayload({ + errorCode: 'login_failed', + errorMessage: 'DocuSign rejected a valid access token.', + nativeDomain: 'DocuSign', + nativeCode: '7', + nativeMessage: 'unauthorized', + underlyingDomain: 'NSURLErrorDomain', + underlyingCode: '-1009', + underlyingMessage: 'offline', + httpStatus: 200, + docusignErrorCode: 'invalid_grant', + docusignMessage: 'expired', + }); + + expect(error.code).toBe('login_failed'); + expect(error.native).toEqual({ + domain: 'DocuSign', + code: '7', + message: 'unauthorized', + underlying: { + domain: 'NSURLErrorDomain', + code: '-1009', + message: 'offline', + }, + }); + expect(error.http).toEqual({ + status: 200, + docusignErrorCode: 'invalid_grant', + docusignMessage: 'expired', + }); + expect(error.reason).toBe('network'); + }); + + it('leaves native and http undefined when the payload carries no details', () => { + const error = fromFailurePayload({ + errorCode: 'signing_failed', + errorMessage: 'failed', + envelopeId: 'env-1', + }); + + expect(error.native).toBeUndefined(); + expect(error.http).toBeUndefined(); + expect(error.envelopeId).toBe('env-1'); + }); + + it('maps a code it does not know to unexpected', () => { + const error = fromFailurePayload({ + errorCode: 'something_new', + errorMessage: 'x', + }); + + expect(error.code).toBe('unexpected'); + }); + + it('falls back to a generic message when native sends an empty one', () => { + const error = fromFailurePayload({ + errorCode: 'signing_failed', + errorMessage: '', + }); + + expect(error.message).toBe('DocuSign operation failed'); + }); +}); + +describe('toDocuSignError', () => { + it('returns a DocuSignError unchanged', () => { + const original = new DocuSignError({ + code: 'signing_failed', + message: 'x', + }); + + expect(toDocuSignError(original)).toBe(original); + }); + + it('keeps a known code from a native rejection', () => { + const rejection = Object.assign(new Error('Call initialize() first.'), { + code: 'not_initialized', + }); + + const error = toDocuSignError(rejection); + + expect(error.code).toBe('not_initialized'); + expect(error.reason).toBe('usage'); + expect(error.message).toBe('Call initialize() first.'); + expect(error.native).toBeUndefined(); + }); + + it('keeps an unknown native code under native so it is not lost', () => { + const rejection = Object.assign(new Error('bad argument'), { + code: 'ERR_ARGUMENT_CAST', + }); + + const error = toDocuSignError(rejection); + + expect(error.code).toBe('unexpected'); + expect(error.native).toEqual({ + code: 'ERR_ARGUMENT_CAST', + message: 'bad argument', + }); + }); + + it('wraps an Error without a code as unexpected', () => { + const error = toDocuSignError(new Error('plain')); + + expect(error.code).toBe('unexpected'); + expect(error.native).toBeUndefined(); + }); + + it('wraps a non-Error value', () => { + const error = toDocuSignError('raw string'); + + expect(error.code).toBe('unexpected'); + expect(error.message).toBe('raw string'); + }); +}); diff --git a/src/DocuSignError.ts b/src/DocuSignError.ts new file mode 100644 index 0000000..8bdbe23 --- /dev/null +++ b/src/DocuSignError.ts @@ -0,0 +1,321 @@ +import type { SigningErrorEvent } from './DocuSign.types'; + +/** + * What failed. Stable across releases, so it is safe to branch on. + * + * `usage` codes mean the app called the package wrongly. The rest describe a + * failure at runtime. + */ +export type DocuSignErrorCode = + | 'initialize_failed' + | 'not_initialized' + | 'not_logged_in' + | 'login_failed' + | 'signing_in_progress' + | 'invalid_signing_url' + | 'presentation_failed' + | 'signing_failed' + | 'unexpected'; + +/** + * Why it failed, set only from facts the package can verify. Anything else is + * `unknown`. A later minor release may turn an `unknown` into a more specific + * reason, so always handle `unknown`. + */ +export type DocuSignErrorReason = + 'usage' | 'network' | 'auth' | 'configuration' | 'recipient' | 'unknown'; + +export type DocuSignUnderlyingError = { + domain?: string; + code?: string; + message?: string; +}; + +/** + * Raw details from the platform. On iOS `domain` and `code` come from the + * `NSError`, on Android `domain` is the exception class and `code` is the + * DocuSign SDK error code when it provides one. + */ +export type DocuSignNativeErrorDetails = DocuSignUnderlyingError & { + /** + * The lower-level error behind the failure when known: `NSUnderlyingErrorKey` + * on iOS, the root of the exception's cause chain on Android, or the transport + * error of a request the package made itself. + */ + underlying?: DocuSignUnderlyingError; +}; + +/** Present when an HTTP response is part of the failure. */ +export type DocuSignHttpErrorDetails = { + status: number; + /** DocuSign's own error code from the response body, such as `UNKNOWN_ENVELOPE_RECIPIENT`. */ + docusignErrorCode?: string; + docusignMessage?: string; +}; + +/** + * Flat, primitive-only attributes for analytics and error reporting tools. + * Low cardinality on purpose: no messages, so dashboards can group on every key. + */ +export type DocuSignErrorAttributes = { + docusign_code: DocuSignErrorCode; + docusign_reason: DocuSignErrorReason; + docusign_envelope_id?: string; + docusign_native_domain?: string; + docusign_native_code?: string; + docusign_underlying_domain?: string; + docusign_underlying_code?: string; + docusign_http_status?: number; + docusign_api_error_code?: string; +}; + +export type DocuSignErrorInit = { + code: DocuSignErrorCode; + message: string; + envelopeId?: string; + native?: DocuSignNativeErrorDetails; + http?: DocuSignHttpErrorDetails; +}; + +const USAGE_CODES: ReadonlySet = new Set([ + 'not_initialized', + 'not_logged_in', + 'signing_in_progress', + 'invalid_signing_url', + 'presentation_failed', +]); + +const KNOWN_CODES: ReadonlySet = new Set([ + 'initialize_failed', + 'not_initialized', + 'not_logged_in', + 'login_failed', + 'signing_in_progress', + 'invalid_signing_url', + 'presentation_failed', + 'signing_failed', + 'unexpected', +]); + +const NETWORK_DOMAINS: ReadonlySet = new Set([ + 'NSURLErrorDomain', + 'java.net.UnknownHostException', + 'java.net.SocketTimeoutException', + 'java.net.ConnectException', +]); + +const RECIPIENT_API_ERROR_CODES: ReadonlySet = new Set([ + 'UNKNOWN_ENVELOPE_RECIPIENT', +]); + +const JWT_PATTERN = + /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*/g; +const BEARER_PATTERN = /\bBearer\s+\S+/gi; +const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>]+/gi; +const TOKEN_LIKE_SEGMENT = /^[A-Za-z0-9_-]{20,}$/; +const TRAILING_PUNCTUATION = /[.,;:!?)\]}]+$/; + +/** + * Signing URLs carry a token in their path and query, and SDK messages can + * echo a URL or a bearer token back. Keep the origin and short path segments so + * a message still says which host failed, and drop everything token shaped. + * + * The URL match also takes punctuation that ends the sentence around it. That + * is peeled off first, or a token segment followed by a period would fail the + * token test and be left in place. + */ +function redactUrl(matched: string): string { + const trailing = TRAILING_PUNCTUATION.exec(matched)?.[0] ?? ''; + const url = matched.slice(0, matched.length - trailing.length); + const withoutQuery = url.split(/[?#]/)[0]; + const match = /^(https?:\/\/[^/]+)(\/.*)?$/i.exec(withoutQuery); + if (!match) return `[redacted-url]${trailing}`; + const [, origin, path = ''] = match; + const segments = path + .split('/') + .map((segment) => + TOKEN_LIKE_SEGMENT.test(segment) ? '[redacted]' : segment, + ); + return origin + segments.join('/') + trailing; +} + +export function redactSecrets(text: string): string { + return text + .replace(JWT_PATTERN, '[redacted-token]') + .replace(BEARER_PATTERN, 'Bearer [redacted]') + .replace(URL_PATTERN, redactUrl); +} + +function redactOptional(text: string | undefined): string | undefined { + return text === undefined ? undefined : redactSecrets(text); +} + +function isNetworkDomain(domain: string | undefined): boolean { + return domain !== undefined && NETWORK_DOMAINS.has(domain); +} + +/** + * Order matters. A caller mistake outranks everything, and a transport failure + * outranks an HTTP status seen earlier in the same flow, because the transport + * failure is what actually stopped the call. + */ +export function deriveReason({ + code, + native, + http, +}: Pick): DocuSignErrorReason { + if (USAGE_CODES.has(code)) return 'usage'; + if ( + isNetworkDomain(native?.domain) || + isNetworkDomain(native?.underlying?.domain) + ) { + return 'network'; + } + if (http?.status === 401 || http?.status === 403) return 'auth'; + if ( + code === 'login_failed' && + http && + http.status >= 200 && + http.status < 300 + ) { + return 'configuration'; + } + if ( + http?.docusignErrorCode && + RECIPIENT_API_ERROR_CODES.has(http.docusignErrorCode) + ) { + return 'recipient'; + } + return 'unknown'; +} + +export class DocuSignError extends Error { + readonly code: DocuSignErrorCode; + readonly reason: DocuSignErrorReason; + readonly envelopeId?: string; + readonly native?: DocuSignNativeErrorDetails; + readonly http?: DocuSignHttpErrorDetails; + + constructor(init: DocuSignErrorInit) { + super(redactSecrets(init.message)); + // Babel's class transform breaks `instanceof` for Error subclasses unless + // the prototype is restored explicitly. + Object.setPrototypeOf(this, new.target.prototype); + this.name = 'DocuSignError'; + this.code = init.code; + this.envelopeId = init.envelopeId; + this.native = init.native && { + ...init.native, + message: redactOptional(init.native.message), + underlying: init.native.underlying && { + ...init.native.underlying, + message: redactOptional(init.native.underlying.message), + }, + }; + this.http = init.http && { + ...init.http, + docusignMessage: redactOptional(init.http.docusignMessage), + }; + this.reason = deriveReason({ + code: init.code, + native: this.native, + http: this.http, + }); + } + + toAttributes(): DocuSignErrorAttributes { + const attributes: DocuSignErrorAttributes = { + docusign_code: this.code, + docusign_reason: this.reason, + }; + if (this.envelopeId) attributes.docusign_envelope_id = this.envelopeId; + if (this.native?.domain) + attributes.docusign_native_domain = this.native.domain; + if (this.native?.code) attributes.docusign_native_code = this.native.code; + if (this.native?.underlying?.domain) { + attributes.docusign_underlying_domain = this.native.underlying.domain; + } + if (this.native?.underlying?.code) { + attributes.docusign_underlying_code = this.native.underlying.code; + } + if (this.http) attributes.docusign_http_status = this.http.status; + if (this.http?.docusignErrorCode) { + attributes.docusign_api_error_code = this.http.docusignErrorCode; + } + return attributes; + } +} + +function nonEmpty(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function toKnownCode(value: unknown): DocuSignErrorCode { + return typeof value === 'string' && KNOWN_CODES.has(value) + ? (value as DocuSignErrorCode) + : 'unexpected'; +} + +/** Builds the error from the failure payload native code resolves with. */ +export function fromFailurePayload(payload: SigningErrorEvent): DocuSignError { + const underlying: DocuSignUnderlyingError | undefined = + payload.underlyingDomain || + payload.underlyingCode || + payload.underlyingMessage + ? { + domain: nonEmpty(payload.underlyingDomain), + code: nonEmpty(payload.underlyingCode), + message: nonEmpty(payload.underlyingMessage), + } + : undefined; + + const hasNative = + payload.nativeDomain || + payload.nativeCode || + payload.nativeMessage || + underlying; + + return new DocuSignError({ + code: toKnownCode(payload.errorCode), + message: nonEmpty(payload.errorMessage) ?? 'DocuSign operation failed', + envelopeId: nonEmpty(payload.envelopeId), + native: hasNative + ? { + domain: nonEmpty(payload.nativeDomain), + code: nonEmpty(payload.nativeCode), + message: nonEmpty(payload.nativeMessage), + underlying, + } + : undefined, + http: + typeof payload.httpStatus === 'number' + ? { + status: payload.httpStatus, + docusignErrorCode: nonEmpty(payload.docusignErrorCode), + docusignMessage: nonEmpty(payload.docusignMessage), + } + : undefined, + }); +} + +/** + * Normalises anything thrown into a `DocuSignError`. Native rejections arrive + * as an Error carrying only `code` and `message`, because the Expo bridge drops + * everything else. + */ +export function toDocuSignError(value: unknown): DocuSignError { + if (value instanceof DocuSignError) return value; + if (value instanceof Error) { + const code = (value as Error & { code?: unknown }).code; + const knownCode = toKnownCode(code); + return new DocuSignError({ + code: knownCode, + message: value.message, + native: + knownCode === 'unexpected' && typeof code === 'string' + ? { code, message: value.message } + : undefined, + }); + } + return new DocuSignError({ code: 'unexpected', message: String(value) }); +} diff --git a/src/DocuSignModule.ts b/src/DocuSignModule.ts index 9247e75..6f7b39f 100644 --- a/src/DocuSignModule.ts +++ b/src/DocuSignModule.ts @@ -7,18 +7,38 @@ import { DocuSignAuthParams, DocuSignConfig, DocuSignModuleEvents, - SigningResult, + SigningErrorEvent, } from './DocuSign.types'; +/** + * Native code rejects only for caller mistakes, because a rejection crosses the + * Expo bridge with nothing but `code` and `message`. Runtime failures resolve + * with this payload instead, so their details survive, and the exported API + * turns them into a thrown `DocuSignError`. + */ +export type NativeFailureOutcome = SigningErrorEvent & { status: 'error' }; + +export type NativeSigningOutcome = + | { + status: 'completed' | 'cancelled'; + envelopeId: string; + errorCode?: string | null; + errorMessage?: string | null; + } + | NativeFailureOutcome; + +export type NativeLoginOutcome = + { status: 'success'; account: DocuSignAccountInfo } | NativeFailureOutcome; + declare class DocuSignModule extends NativeModule { - initialize(config: DocuSignConfig): Promise; - loginWithAccessToken( - params: DocuSignAuthParams, - ): Promise; - presentCaptiveSigning(params: CaptiveSigningParams): Promise; + initialize(config: DocuSignConfig): Promise; + loginWithAccessToken(params: DocuSignAuthParams): Promise; + presentCaptiveSigning( + params: CaptiveSigningParams, + ): Promise; presentCaptiveSigningWithUrl( params: CaptiveSigningUrlParams, - ): Promise; + ): Promise; logout(): Promise; isLoggedIn(): Promise; endSigningSession(): Promise; diff --git a/src/api.test.ts b/src/api.test.ts index 9d901da..1f73ca1 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -1,23 +1,408 @@ -import { expect, it, jest } from '@jest/globals'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { DocuSignError } from './DocuSignError'; import DocuSignModule from './DocuSignModule'; -import { presentCaptiveSigningWithUrl } from './api'; - -it('delegates presentCaptiveSigningWithUrl to the native module', () => { - const mockPresentCaptiveSigningWithUrl = jest.mocked( - DocuSignModule.presentCaptiveSigningWithUrl, - ); - const params = { - signingUrl: 'https://demo.docusign.net/signing/example', - envelopeId: 'envelope-id', - recipientId: 'recipient-id', - }; - const nativeResult = Promise.resolve({ - status: 'completed' as const, - envelopeId: params.envelopeId, - }); - mockPresentCaptiveSigningWithUrl.mockReturnValue(nativeResult); - - expect(presentCaptiveSigningWithUrl(params)).toBe(nativeResult); - expect(mockPresentCaptiveSigningWithUrl).toHaveBeenCalledWith(params); +import { + addLoginAttemptListener, + addSigningCancelledListener, + addSigningCompleteListener, + addSigningErrorListener, + DocuSignErrorListener, + endSigningSession, + initialize, + isLoggedIn, + loginWithAccessToken, + logout, + presentCaptiveSigning, + presentCaptiveSigningWithUrl, + reset, +} from './api'; + +const nativeModule = jest.mocked(DocuSignModule); + +const sessionParams = { + envelopeId: 'env-1', + recipientUserName: 'Recipient', + recipientEmail: 'recipient@example.com', + recipientClientUserId: 'client-1', +}; + +const urlParams = { + signingUrl: 'https://demo.docusign.net/signing/example', + envelopeId: 'env-2', + recipientId: 'recipient-id', +}; + +const account = { + accountId: 'account-1', + userId: 'user-1', + userName: 'User', + email: 'user@example.com', +}; + +const nativeRejection = (code: string, message: string) => + Object.assign(new Error(message), { code }); + +const captureRejection = async (promise: Promise) => { + try { + await promise; + } catch (error) { + return error; + } + throw new Error('expected the promise to reject'); +}; + +let warn: jest.SpiedFunction; +let subscriptions: { remove(): void }[] = []; + +const listen = (listener: DocuSignErrorListener) => { + const subscription = addSigningErrorListener(listener); + subscriptions.push(subscription); + return subscription; +}; + +beforeEach(() => { + jest.clearAllMocks(); + warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); +}); + +afterEach(() => { + subscriptions.forEach((subscription) => subscription.remove()); + subscriptions = []; + warn.mockRestore(); +}); + +describe('presentCaptiveSigning', () => { + it('resolves a completed outcome without the native extras', async () => { + nativeModule.presentCaptiveSigning.mockResolvedValue({ + status: 'completed', + envelopeId: 'env-1', + errorCode: null, + errorMessage: null, + }); + + await expect(presentCaptiveSigning(sessionParams)).resolves.toEqual({ + status: 'completed', + envelopeId: 'env-1', + }); + expect(nativeModule.presentCaptiveSigning).toHaveBeenCalledWith( + sessionParams, + ); + }); + + it('keeps the cancel reason on a cancelled outcome', async () => { + nativeModule.presentCaptiveSigning.mockResolvedValue({ + status: 'cancelled', + envelopeId: 'env-1', + errorMessage: 'session_ended', + }); + + await expect(presentCaptiveSigning(sessionParams)).resolves.toEqual({ + status: 'cancelled', + envelopeId: 'env-1', + errorMessage: 'session_ended', + }); + }); + + it('keeps an error code that native sends on a resolved outcome', async () => { + nativeModule.presentCaptiveSigning.mockResolvedValue({ + status: 'cancelled', + envelopeId: 'env-1', + errorCode: 'exit', + }); + + await expect(presentCaptiveSigning(sessionParams)).resolves.toEqual({ + status: 'cancelled', + envelopeId: 'env-1', + errorCode: 'exit', + }); + }); + + it('rejects with a DocuSignError built from a failure outcome', async () => { + nativeModule.presentCaptiveSigning.mockResolvedValue({ + status: 'error', + envelopeId: 'env-1', + errorCode: 'signing_failed', + errorMessage: 'DocuSign ended the signing ceremony with an error.', + nativeDomain: 'NSURLErrorDomain', + nativeCode: '-1005', + }); + + const error = await captureRejection(presentCaptiveSigning(sessionParams)); + + expect(error).toBeInstanceOf(DocuSignError); + expect(error).toMatchObject({ + code: 'signing_failed', + reason: 'network', + envelopeId: 'env-1', + native: { domain: 'NSURLErrorDomain', code: '-1005' }, + }); + }); + + it('rejects with a usage DocuSignError when native rejects a caller mistake', async () => { + nativeModule.presentCaptiveSigning.mockRejectedValue( + nativeRejection('not_logged_in', 'Call loginWithAccessToken() first.'), + ); + + const error = await captureRejection(presentCaptiveSigning(sessionParams)); + + expect(error).toBeInstanceOf(DocuSignError); + expect(error).toMatchObject({ code: 'not_logged_in', reason: 'usage' }); + }); +}); + +describe('presentCaptiveSigningWithUrl', () => { + it('delegates to the native module and resolves its outcome', async () => { + nativeModule.presentCaptiveSigningWithUrl.mockResolvedValue({ + status: 'completed', + envelopeId: 'env-2', + }); + + await expect(presentCaptiveSigningWithUrl(urlParams)).resolves.toEqual({ + status: 'completed', + envelopeId: 'env-2', + }); + expect(nativeModule.presentCaptiveSigningWithUrl).toHaveBeenCalledWith( + urlParams, + ); + }); + + it('rejects with invalid_signing_url when native refuses the URL', async () => { + nativeModule.presentCaptiveSigningWithUrl.mockRejectedValue( + nativeRejection( + 'invalid_signing_url', + 'signingUrl must be a non-empty https URL.', + ), + ); + + const error = await captureRejection( + presentCaptiveSigningWithUrl(urlParams), + ); + + expect(error).toMatchObject({ + code: 'invalid_signing_url', + reason: 'usage', + }); + }); +}); + +describe('loginWithAccessToken', () => { + it('resolves the account from a success outcome', async () => { + nativeModule.loginWithAccessToken.mockResolvedValue({ + status: 'success', + account, + }); + + await expect( + loginWithAccessToken({ accessToken: 'token' }), + ).resolves.toEqual(account); + }); + + it('rejects with auth when the userinfo check returned 401', async () => { + nativeModule.loginWithAccessToken.mockResolvedValue({ + status: 'error', + errorCode: 'login_failed', + errorMessage: 'DocuSign rejected the access token.', + httpStatus: 401, + }); + + const error = await captureRejection( + loginWithAccessToken({ accessToken: 'token' }), + ); + + expect(error).toMatchObject({ + code: 'login_failed', + reason: 'auth', + http: { status: 401 }, + }); + }); +}); + +describe('initialize', () => { + it('resolves when native resolves', async () => { + nativeModule.initialize.mockResolvedValue(null); + + await expect( + initialize({ integratorKey: 'key', environment: 'demo' }), + ).resolves.toBeUndefined(); + }); + + it('rejects with the SDK details when native resolves an initialization failure', async () => { + nativeModule.initialize.mockResolvedValue({ + status: 'error', + errorCode: 'initialize_failed', + errorMessage: 'DocuSign SDK could not be initialized: bad state', + nativeDomain: 'java.lang.IllegalStateException', + nativeMessage: 'bad state', + }); + + const error = await captureRejection( + initialize({ integratorKey: 'key', environment: 'demo' }), + ); + + expect(error).toBeInstanceOf(DocuSignError); + expect(error).toMatchObject({ + code: 'initialize_failed', + reason: 'unknown', + native: { domain: 'java.lang.IllegalStateException' }, + }); + }); + + it('rejects with a DocuSignError when native rejects', async () => { + nativeModule.initialize.mockRejectedValue( + nativeRejection('initialize_failed', 'Invalid DocuSign host URL.'), + ); + + const error = await captureRejection( + initialize({ integratorKey: 'key', environment: 'demo' }), + ); + + expect(error).toMatchObject({ + code: 'initialize_failed', + reason: 'unknown', + }); + }); +}); + +describe('addSigningErrorListener', () => { + it('delivers the same error the caller receives, exactly once', async () => { + const listener = jest.fn(); + listen(listener); + nativeModule.presentCaptiveSigning.mockResolvedValue({ + status: 'error', + envelopeId: 'env-1', + errorCode: 'signing_failed', + errorMessage: 'failed', + }); + + const error = await captureRejection(presentCaptiveSigning(sessionParams)); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(error); + }); + + it('delivers caller mistakes too', async () => { + const listener = jest.fn(); + listen(listener); + nativeModule.presentCaptiveSigning.mockRejectedValue( + nativeRejection('not_initialized', 'Call initialize() first.'), + ); + + await captureRejection(presentCaptiveSigning(sessionParams)); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ code: 'not_initialized', reason: 'usage' }), + ); + }); + + it('stops delivering after remove', async () => { + const listener = jest.fn(); + listen(listener).remove(); + nativeModule.presentCaptiveSigning.mockRejectedValue( + nativeRejection('not_initialized', 'Call initialize() first.'), + ); + + await captureRejection(presentCaptiveSigning(sessionParams)); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('still rejects with the original error when a listener throws', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + listen(() => { + throw new Error('broken logger'); + }); + nativeModule.presentCaptiveSigning.mockRejectedValue( + nativeRejection('not_initialized', 'Call initialize() first.'), + ); + + const error = await captureRejection(presentCaptiveSigning(sessionParams)); + + expect(error).toMatchObject({ code: 'not_initialized' }); + expect(consoleError).toHaveBeenCalledTimes(1); + consoleError.mockRestore(); + }); +}); + +describe('session teardown and state', () => { + it('delegates logout to the native module', async () => { + nativeModule.logout.mockResolvedValue(undefined); + + await logout(); + + expect(nativeModule.logout).toHaveBeenCalledTimes(1); + }); + + it('returns the native login state', async () => { + nativeModule.isLoggedIn.mockResolvedValue(true); + + await expect(isLoggedIn()).resolves.toBe(true); + }); + + it('delegates endSigningSession to the native module', async () => { + nativeModule.endSigningSession.mockResolvedValue(undefined); + + await endSigningSession(); + + expect(nativeModule.endSigningSession).toHaveBeenCalledTimes(1); + }); + + it('delegates reset to the native module', async () => { + nativeModule.reset.mockResolvedValue(undefined); + + await reset(); + + expect(nativeModule.reset).toHaveBeenCalledTimes(1); + }); +}); + +describe('native event listeners', () => { + it.each([ + ['onSigningComplete', addSigningCompleteListener], + ['onSigningCancelled', addSigningCancelledListener], + ['onLoginAttempt', addLoginAttemptListener], + ] as const)('subscribes %s on the native module', (eventName, subscribe) => { + const listener = jest.fn(); + + subscribe(listener); + + expect(nativeModule.addListener).toHaveBeenCalledWith(eventName, listener); + }); +}); + +describe('development warning', () => { + it('warns once for a usage error, naming the code and the fix', async () => { + nativeModule.presentCaptiveSigning.mockRejectedValue( + nativeRejection('not_initialized', 'Call initialize() first.'), + ); + + await captureRejection(presentCaptiveSigning(sessionParams)); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + '[react-native-docusign] not_initialized: Call initialize() first.', + ); + }); + + it('does not warn for a runtime failure', async () => { + nativeModule.presentCaptiveSigning.mockResolvedValue({ + status: 'error', + envelopeId: 'env-1', + errorCode: 'signing_failed', + errorMessage: 'failed', + }); + + await captureRejection(presentCaptiveSigning(sessionParams)); + + expect(warn).not.toHaveBeenCalled(); + }); }); diff --git a/src/api.ts b/src/api.ts index 24389f0..0ea81c3 100644 --- a/src/api.ts +++ b/src/api.ts @@ -7,29 +7,105 @@ import { LoginAttemptEvent, SigningCancelledEvent, SigningCompleteEvent, - SigningErrorEvent, SigningResult, } from './DocuSign.types'; -import DocuSignModule from './DocuSignModule'; +import { + DocuSignError, + fromFailurePayload, + toDocuSignError, +} from './DocuSignError'; +import DocuSignModule, { NativeSigningOutcome } from './DocuSignModule'; export type DocuSignSubscription = { remove(): void; }; -export function initialize(config: DocuSignConfig): Promise { - return DocuSignModule.initialize(config); +export type DocuSignErrorListener = (error: DocuSignError) => void; + +const errorListeners = new Set(); + +/** + * Every failure passes through here exactly once before it is thrown, so a + * listener sees the same errors a `catch` does, caller mistakes included. + */ +function report(error: DocuSignError): DocuSignError { + if (__DEV__ && error.reason === 'usage') { + console.warn(`[react-native-docusign] ${error.code}: ${error.message}`); + } + errorListeners.forEach((listener) => { + try { + listener(error); + } catch (listenerError) { + // A broken logging callback must not replace the signing failure the + // caller is about to receive. + if (__DEV__) { + console.error( + '[react-native-docusign] error listener threw', + listenerError, + ); + } + } + }); + return error; +} + +async function callNative(call: () => Promise): Promise { + try { + return await call(); + } catch (nativeRejection) { + throw report(toDocuSignError(nativeRejection)); + } } -export function loginWithAccessToken( +function settleSigningOutcome(outcome: NativeSigningOutcome): SigningResult { + if (outcome.status === 'error') { + throw report(fromFailurePayload(outcome)); + } + return { + status: outcome.status, + envelopeId: outcome.envelopeId, + ...(outcome.errorCode ? { errorCode: outcome.errorCode } : {}), + ...(outcome.errorMessage ? { errorMessage: outcome.errorMessage } : {}), + }; +} + +/** + * Configures the underlying DocuSign SDK. Rejects with a `DocuSignError`. + */ +export async function initialize(config: DocuSignConfig): Promise { + const outcome = await callNative(() => DocuSignModule.initialize(config)); + if (outcome?.status === 'error') { + throw report(fromFailurePayload(outcome)); + } +} + +/** + * Logs the SDK in with an access token. Rejects with a `DocuSignError` whose + * `reason` tells an expired token (`auth`) from a DocuSign account that is not + * set up for the mobile SDK (`configuration`). + */ +export async function loginWithAccessToken( params: DocuSignAuthParams, ): Promise { - return DocuSignModule.loginWithAccessToken(params); + const outcome = await callNative(() => + DocuSignModule.loginWithAccessToken(params), + ); + if (outcome.status === 'error') { + throw report(fromFailurePayload(outcome)); + } + return outcome.account; } -export function presentCaptiveSigning( +/** + * Presents captive signing. Resolves with `completed` or `cancelled`, and + * rejects with a `DocuSignError` for every failure. + */ +export async function presentCaptiveSigning( params: CaptiveSigningParams, ): Promise { - return DocuSignModule.presentCaptiveSigning(params); + return settleSigningOutcome( + await callNative(() => DocuSignModule.presentCaptiveSigning(params)), + ); } /** @@ -39,12 +115,15 @@ export function presentCaptiveSigning( * encodes recipient identity via a short-lived token. {@link initialize} is * still required. * - * Supported on iOS and Android. + * Supported on iOS and Android. Resolves with `completed` or `cancelled`, and + * rejects with a `DocuSignError` for every failure. */ -export function presentCaptiveSigningWithUrl( +export async function presentCaptiveSigningWithUrl( params: CaptiveSigningUrlParams, ): Promise { - return DocuSignModule.presentCaptiveSigningWithUrl(params); + return settleSigningOutcome( + await callNative(() => DocuSignModule.presentCaptiveSigningWithUrl(params)), + ); } export function logout(): Promise { @@ -100,10 +179,21 @@ export function addSigningCancelledListener( return DocuSignModule.addListener('onSigningCancelled', listener); } +/** + * Receives every `DocuSignError` raised by `initialize`, `loginWithAccessToken`, + * `presentCaptiveSigning` and `presentCaptiveSigningWithUrl`, caller mistakes + * included, once each and before the call rejects. Register it once at startup + * to send failures to your analytics or error reporting tool. + */ export function addSigningErrorListener( - listener: (event: SigningErrorEvent) => void, + listener: DocuSignErrorListener, ): DocuSignSubscription { - return DocuSignModule.addListener('onSigningError', listener); + errorListeners.add(listener); + return { + remove: () => { + errorListeners.delete(listener); + }, + }; } export function addLoginAttemptListener( diff --git a/src/index.ts b/src/index.ts index bad22e0..2bcb9d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,15 @@ export * from './api'; export { default as DocuSignModule } from './DocuSignModule'; export * from './DocuSign.types'; +export { DocuSignError } from './DocuSignError'; +export type { + DocuSignErrorAttributes, + DocuSignErrorCode, + DocuSignErrorReason, + DocuSignHttpErrorDetails, + DocuSignNativeErrorDetails, + DocuSignUnderlyingError, +} from './DocuSignError'; export { useDocuSignSigning } from './useDocuSignSigning'; export type { DocuSignSigningState, diff --git a/src/useDocuSignSigning.test.ts b/src/useDocuSignSigning.test.ts index d4dde05..7f31e24 100644 --- a/src/useDocuSignSigning.test.ts +++ b/src/useDocuSignSigning.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { DocuSignError } from './DocuSignError'; import * as api from './api'; import { SIGNING_STATE, useDocuSignSigning } from './useDocuSignSigning'; @@ -136,8 +137,12 @@ describe('useDocuSignSigning', () => { expect(result.current.state).toBe(SIGNING_STATE.CANCELLED); }); - it('captures error outcome into state and rethrows', async () => { - const failure = new Error('SDK rejected token'); + it('captures a DocuSignError from the api into state and rethrows it unchanged', async () => { + const failure = new DocuSignError({ + code: 'login_failed', + message: 'SDK rejected token', + http: { status: 401 }, + }); mockedApi.loginWithAccessToken.mockRejectedValue(failure); const { result } = renderHook(() => useDocuSignSigning({ config })); @@ -163,6 +168,37 @@ describe('useDocuSignSigning', () => { expect(result.current.error).toBe(failure); }); + it('wraps anything that is not a DocuSignError before storing it', async () => { + mockedApi.presentCaptiveSigning.mockRejectedValue( + new Error('unexpected crash'), + ); + + const { result } = renderHook(() => useDocuSignSigning({ config })); + + await waitFor(() => { + expect(result.current.state).toBe(SIGNING_STATE.READY); + }); + + await act(async () => { + await expect( + result.current.startSigning({ + type: 'session', + accessToken: 'token', + envelopeId: 'env-1', + recipientUserName: 'r', + recipientEmail: 'r@example.com', + recipientClientUserId: 'client-1', + }), + ).rejects.toBeInstanceOf(DocuSignError); + }); + + expect(result.current.error).toBeInstanceOf(DocuSignError); + expect(result.current.error).toMatchObject({ + code: 'unexpected', + message: 'unexpected crash', + }); + }); + it('reset() calls endSigningSession when initialized and returns to ready', async () => { const { result } = renderHook(() => useDocuSignSigning({ config })); @@ -195,14 +231,16 @@ describe('useDocuSignSigning', () => { expect(result.current.state).toBe(SIGNING_STATE.IDLE); }); - it('subscribes to signing errors and surfaces them into error state', async () => { - let listener: - | ((event: { errorCode: string; errorMessage: string }) => void) - | undefined; + it('subscribes to signing errors and stores the delivered DocuSignError', async () => { + let listener: api.DocuSignErrorListener | undefined; mockedApi.addSigningErrorListener.mockImplementation((cb) => { listener = cb; return { remove: jest.fn() }; }); + const delivered = new DocuSignError({ + code: 'signing_failed', + message: 'boom', + }); const { result } = renderHook(() => useDocuSignSigning({ config })); @@ -211,11 +249,10 @@ describe('useDocuSignSigning', () => { }); act(() => { - listener?.({ errorCode: 'signing_failed', errorMessage: 'boom' }); + listener?.(delivered); }); - expect(result.current.error).toBeInstanceOf(Error); - expect(result.current.error?.message).toBe('signing_failed: boom'); + expect(result.current.error).toBe(delivered); }); it('removes the error listener on unmount', () => { @@ -231,7 +268,10 @@ describe('useDocuSignSigning', () => { }); it('captures initialization failure into error state', async () => { - const failure = new Error('init failed'); + const failure = new DocuSignError({ + code: 'initialize_failed', + message: 'init failed', + }); mockedApi.initialize.mockRejectedValue(failure); const { result } = renderHook(() => useDocuSignSigning({ config })); diff --git a/src/useDocuSignSigning.ts b/src/useDocuSignSigning.ts index 15390b9..9cbd831 100644 --- a/src/useDocuSignSigning.ts +++ b/src/useDocuSignSigning.ts @@ -7,6 +7,7 @@ import { DocuSignConfig, SigningResult, } from './DocuSign.types'; +import { DocuSignError, toDocuSignError } from './DocuSignError'; import { addSigningErrorListener, endSigningSession, @@ -55,7 +56,7 @@ export type UseDocuSignSigningOptions = { export type UseDocuSignSigningReturn = { state: DocuSignSigningState; - error: Error | null; + error: DocuSignError | null; result: SigningResult | null; initialize: () => Promise; startSigning: (session: SigningSession) => Promise; @@ -68,7 +69,7 @@ export function useDocuSignSigning( const { config, autoInitialize = true } = options; const [state, setState] = useState(SIGNING_STATE.IDLE); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [result, setResult] = useState(null); const initializedRef = useRef(false); @@ -81,7 +82,7 @@ export function useDocuSignSigning( initializedRef.current = true; setState(SIGNING_STATE.READY); } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); + const err = toDocuSignError(e); setError(err); setState(SIGNING_STATE.ERROR); throw err; @@ -105,8 +106,8 @@ export function useDocuSignSigning( ); useEffect(function attachErrorListener() { - const errorSub = addSigningErrorListener((event) => { - setError(new Error(`${event.errorCode}: ${event.errorMessage}`)); + const errorSub = addSigningErrorListener((signingError) => { + setError(signingError); }); return () => { errorSub.remove(); @@ -161,7 +162,7 @@ export function useDocuSignSigning( setState(stateForResult(signingResult.status)); return signingResult; } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); + const err = toDocuSignError(e); setError(err); setState(SIGNING_STATE.ERROR); throw err; diff --git a/tsconfig.examples.json b/tsconfig.examples.json new file mode 100644 index 0000000..8a0d2be --- /dev/null +++ b/tsconfig.examples.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "paths": { + "react-native-docusign": ["./src/index.ts"] + } + }, + "include": ["./examples", "./src"], + "exclude": ["**/*.test.ts", "**/*.test.tsx", "node_modules", "build"] +}