@glomopay/react-native-sdk 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/MIGRATION.md +112 -0
  3. package/README.md +173 -36
  4. package/lib/bridge/file-input-handler.d.ts +25 -0
  5. package/lib/bridge/file-input-handler.d.ts.map +1 -0
  6. package/lib/bridge/file-input-handler.js +38 -0
  7. package/lib/bridge/file-save-handler.d.ts +32 -0
  8. package/lib/bridge/file-save-handler.d.ts.map +1 -0
  9. package/lib/bridge/file-save-handler.js +92 -0
  10. package/lib/bridge/message-router.d.ts +44 -0
  11. package/lib/bridge/message-router.d.ts.map +1 -0
  12. package/lib/bridge/message-router.js +48 -0
  13. package/lib/bridge/payment-payload.d.ts +18 -0
  14. package/lib/bridge/payment-payload.d.ts.map +1 -0
  15. package/lib/bridge/payment-payload.js +31 -0
  16. package/lib/capabilities/file-save.d.ts +40 -0
  17. package/lib/capabilities/file-save.d.ts.map +1 -0
  18. package/lib/capabilities/file-save.js +101 -0
  19. package/lib/capabilities/index.d.ts +22 -0
  20. package/lib/capabilities/index.d.ts.map +1 -0
  21. package/lib/capabilities/index.js +34 -0
  22. package/lib/capabilities/use-capability-reporting.d.ts +24 -0
  23. package/lib/capabilities/use-capability-reporting.d.ts.map +1 -0
  24. package/lib/capabilities/use-capability-reporting.js +44 -0
  25. package/lib/config/base.d.ts +12 -0
  26. package/lib/config/base.d.ts.map +1 -1
  27. package/lib/config/base.js +13 -1
  28. package/lib/glomo-checkout.d.ts +9 -1
  29. package/lib/glomo-checkout.d.ts.map +1 -1
  30. package/lib/glomo-checkout.js +14 -5
  31. package/lib/glomo-lrs-checkout.d.ts +3 -0
  32. package/lib/glomo-lrs-checkout.d.ts.map +1 -1
  33. package/lib/glomo-lrs-checkout.js +34 -11
  34. package/lib/glomo-standard-checkout.d.ts +4 -1
  35. package/lib/glomo-standard-checkout.d.ts.map +1 -1
  36. package/lib/glomo-standard-checkout.js +21 -13
  37. package/lib/glomo-subscriptions-checkout.d.ts.map +1 -1
  38. package/lib/glomo-subscriptions-checkout.js +1 -1
  39. package/lib/index.d.ts +2 -2
  40. package/lib/index.d.ts.map +1 -1
  41. package/lib/index.js +4 -3
  42. package/lib/injections/index.d.ts +24 -0
  43. package/lib/injections/index.d.ts.map +1 -1
  44. package/lib/injections/index.js +41 -0
  45. package/lib/injections/webview-flow.injection.d.ts.map +1 -1
  46. package/lib/injections/webview-flow.injection.js +76 -0
  47. package/lib/injections/webview-main.injection.d.ts.map +1 -1
  48. package/lib/injections/webview-main.injection.js +31 -0
  49. package/lib/injections/webview-standard.injection.d.ts.map +1 -1
  50. package/lib/injections/webview-standard.injection.js +31 -0
  51. package/lib/services/file-saver.d.ts +109 -0
  52. package/lib/services/file-saver.d.ts.map +1 -0
  53. package/lib/services/file-saver.js +416 -0
  54. package/lib/types/checkout.d.ts +67 -6
  55. package/lib/types/checkout.d.ts.map +1 -1
  56. package/lib/types/checkout.js +24 -2
  57. package/lib/types/standard-checkout.d.ts +6 -6
  58. package/lib/types/standard-checkout.d.ts.map +1 -1
  59. package/lib/types/subscriptions-checkout.d.ts +5 -3
  60. package/lib/types/subscriptions-checkout.d.ts.map +1 -1
  61. package/lib/use-glomo-checkout.d.ts +2 -2
  62. package/lib/use-glomo-checkout.d.ts.map +1 -1
  63. package/lib/use-glomo-checkout.js +25 -4
  64. package/lib/use-lrs-checkout.d.ts +18 -6
  65. package/lib/use-lrs-checkout.d.ts.map +1 -1
  66. package/lib/use-lrs-checkout.js +330 -171
  67. package/lib/use-standard-checkout.d.ts +18 -13
  68. package/lib/use-standard-checkout.d.ts.map +1 -1
  69. package/lib/use-standard-checkout.js +282 -206
  70. package/lib/utils/analytics.d.ts +177 -51
  71. package/lib/utils/analytics.d.ts.map +1 -1
  72. package/lib/utils/analytics.js +299 -81
  73. package/lib/utils/device-compliance.d.ts +57 -12
  74. package/lib/utils/device-compliance.d.ts.map +1 -1
  75. package/lib/utils/device-compliance.js +101 -37
  76. package/lib/utils/event-projections.d.ts +56 -0
  77. package/lib/utils/event-projections.d.ts.map +1 -0
  78. package/lib/utils/event-projections.js +42 -0
  79. package/lib/utils/file-metadata.d.ts +52 -0
  80. package/lib/utils/file-metadata.d.ts.map +1 -0
  81. package/lib/utils/file-metadata.js +134 -0
  82. package/lib/utils/use-sdk-session.d.ts +12 -0
  83. package/lib/utils/use-sdk-session.d.ts.map +1 -0
  84. package/lib/utils/use-sdk-session.js +37 -0
  85. package/lib/utils/validation.d.ts +37 -3
  86. package/lib/utils/validation.d.ts.map +1 -1
  87. package/lib/utils/validation.js +64 -8
  88. package/lib/webview/capability-props.d.ts +27 -0
  89. package/lib/webview/capability-props.d.ts.map +1 -0
  90. package/lib/webview/capability-props.js +61 -0
  91. package/lib/webview/use-permission-request.d.ts +33 -0
  92. package/lib/webview/use-permission-request.d.ts.map +1 -0
  93. package/lib/webview/use-permission-request.js +89 -0
  94. package/package.json +13 -3
  95. package/src/bridge/file-input-handler.ts +66 -0
  96. package/src/bridge/file-save-handler.ts +141 -0
  97. package/src/bridge/message-router.ts +92 -0
  98. package/src/bridge/payment-payload.ts +31 -0
  99. package/src/capabilities/file-save.ts +124 -0
  100. package/src/capabilities/index.ts +34 -0
  101. package/src/capabilities/use-capability-reporting.ts +55 -0
  102. package/src/config/base.ts +13 -0
  103. package/src/glomo-checkout.tsx +28 -11
  104. package/src/glomo-lrs-checkout.tsx +49 -27
  105. package/src/glomo-standard-checkout.tsx +33 -34
  106. package/src/glomo-subscriptions-checkout.tsx +1 -0
  107. package/src/index.ts +4 -2
  108. package/src/injections/index.ts +43 -0
  109. package/src/injections/webview-flow.injection.ts +76 -0
  110. package/src/injections/webview-main.injection.ts +31 -0
  111. package/src/injections/webview-standard.injection.ts +31 -0
  112. package/src/services/file-saver.ts +497 -0
  113. package/src/types/checkout.ts +72 -6
  114. package/src/types/standard-checkout.ts +6 -6
  115. package/src/types/subscriptions-checkout.ts +9 -3
  116. package/src/use-glomo-checkout.tsx +32 -7
  117. package/src/use-lrs-checkout.tsx +684 -366
  118. package/src/use-standard-checkout.tsx +731 -465
  119. package/src/utils/analytics.ts +422 -117
  120. package/src/utils/device-compliance.ts +148 -38
  121. package/src/utils/event-projections.ts +74 -0
  122. package/src/utils/file-metadata.ts +140 -0
  123. package/src/utils/use-sdk-session.ts +41 -0
  124. package/src/utils/validation.ts +70 -10
  125. package/src/webview/capability-props.ts +86 -0
  126. package/src/webview/use-permission-request.ts +114 -0
  127. package/src/jail-monkey.d.ts +0 -19
@@ -1,75 +1,185 @@
1
1
  /**
2
- * Device compliance utilities for the GlomoPay RN SDK
2
+ * Device compliance for the GlomoPay RN SDK
3
3
  *
4
- * These utilities check device security compliance using jail-monkey (if available).
5
- * If jail-monkey is not installed, compliance checks are skipped.
4
+ * Whether a checkout may run on this device, from `jail-monkey`'s root/jailbreak detection.
6
5
  *
7
- * Merchants are STRONGLY encouraged to install peerDependencies for better security compliance.
6
+ * The gate exists to protect a **compromised customer device**: malware on a real user's rooted
7
+ * phone reading cached KYC documents. That framing is what decides the two judgement calls below -
8
+ * which root signals to trust, and when not to gate at all.
8
9
  */
9
10
 
11
+ import { isMockModeKey } from "./validation";
12
+
13
+ /** The package name, so a missing dependency can be named in a warning and in telemetry */
14
+ export const COMPLIANCE_PACKAGE = "jail-monkey";
15
+
16
+ /**
17
+ * rootBeer checks deliberately excluded from our verdict.
18
+ *
19
+ * Both fire on a stock AOSP emulator image - `ro.build.tags=test-keys`, and `ro.debuggable=1` /
20
+ * `ro.secure=0` - so an Android emulator reports as rooted through `isJailBroken()`, which is a
21
+ * hardcoded OR over every check and is not configurable. Merchants integration-test with live keys
22
+ * on emulators; blocking that turns integration into a support ticket, and an emulator is not the
23
+ * threat this gate exists for - there is no unwitting victim whose documents are being read.
24
+ *
25
+ * What this costs is custom-ROM detection on physical devices, the weakest signal in the set. Every
26
+ * strong indicator is kept: a genuinely rooted handset trips su, Magisk or a root-manager app
27
+ * essentially always.
28
+ */
29
+ export const IGNORED_ROOT_SIGNALS = ["detectTestKeys", "checkForDangerousProps"];
30
+
31
+ /** How the verdict below was reached. Rides telemetry, because the three are not equally strong. */
32
+ export type ComplianceSource =
33
+ /** Per-check rootBeer breakdown, with IGNORED_ROOT_SIGNALS excluded. Android only. */
34
+ | "decomposed"
35
+
36
+ /** jail-monkey's own `isJailBroken()`. iOS, and Android builds that expose no breakdown. */
37
+ | "aggregate"
38
+
39
+ /** jail-monkey could not be loaded at all. */
40
+ | "unavailable";
41
+
42
+ /** What the probe concluded */
43
+ export interface DeviceComplianceResult {
44
+ /** True if compromised, false if clean, **null if it could not be determined** - never "clean" */
45
+ compromised: boolean | null;
46
+
47
+ source: ComplianceSource;
48
+
49
+ /** Names of the checks that tripped. Never the device identifiers behind them. */
50
+ trippedSignals: string[];
51
+ }
52
+
10
53
  /**
11
- * Safely checks if jail-monkey is available
12
- * Returns null if the library is not installed
54
+ * The subset of jail-monkey this SDK uses.
55
+ *
56
+ * Fields are pulled from the real package's declarations rather than restated, so a rename upstream
57
+ * - `androidRootedDetectionMethods` in particular - is a compile error here instead of a probe that
58
+ * silently falls back to the aggregate check forever. `typeof import(...)` is erased, so this costs
59
+ * no runtime import of an optional dependency.
60
+ */
61
+ type PublishedJailMonkey = typeof import("jail-monkey").default;
62
+
63
+ interface JailMonkeyModule {
64
+ isJailBroken: PublishedJailMonkey["isJailBroken"];
65
+ androidRootedDetectionMethods?: PublishedJailMonkey["androidRootedDetectionMethods"];
66
+ }
67
+
68
+ /**
69
+ * Loads jail-monkey, or null.
70
+ *
71
+ * Literal `require` directly inside the `try`, resolved once at module init. Both are what Metro
72
+ * requires of an optional dependency, and the second is the one that is easy to get wrong: a lazy
73
+ * first `require` runs outside Metro's `inGuard`, where a failed load is reported through
74
+ * `ErrorUtils.reportFatalError` and raises a full-screen LogBox in dev builds even though the
75
+ * `catch` here degrades correctly. See the full note in capabilities/file-save.ts.
76
+ *
77
+ * Also catches the installed-but-not-rebuilt case: jail-monkey reads
78
+ * `NativeModules.JailMonkey.rootedDetectionMethods` at module scope, so an unlinked native module
79
+ * throws on import rather than degrading.
13
80
  */
14
- function getJailMonkey(): { isJailBroken: () => boolean } | null {
81
+ const jailMonkeyModule: JailMonkeyModule | null = (() => {
15
82
  try {
16
- const JailMonkey = require("jail-monkey");
17
- if (JailMonkey && typeof JailMonkey.isJailBroken === "function") {
18
- return JailMonkey;
19
- }
20
- return null;
83
+ const loaded = require("jail-monkey");
84
+ const module = (loaded?.default ?? loaded) as JailMonkeyModule | undefined;
85
+ return module && typeof module.isJailBroken === "function" ? module : null;
21
86
  } catch {
22
- // jail-monkey is not installed
23
87
  return null;
24
88
  }
89
+ })();
90
+
91
+ function loadJailMonkey(): JailMonkeyModule | null {
92
+ return jailMonkeyModule;
25
93
  }
26
94
 
27
95
  /**
28
- * Checks if the device is rooted or jailbroken
29
- * Returns null if jail-monkey is not available (compliance check skipped)
30
- * Returns true if device is compromised, false if device is safe
96
+ * Whether this device is compromised.
31
97
  *
32
- * @param devMode - Whether to log debug information
33
- * @returns true if device is compromised, false if safe, null if check unavailable
98
+ * Prefers the per-check breakdown over `isJailBroken()`, because the aggregate is a hardcoded OR
99
+ * that includes the two emulator-tripping checks. Falls back to the aggregate whenever the
100
+ * breakdown is absent - iOS never reports one, and a future jail-monkey that stops exporting it
101
+ * degrades to today's behaviour rather than to "clean".
34
102
  */
35
- export function checkDeviceCompliance(devMode: boolean = false): boolean | null {
36
- const JailMonkey = getJailMonkey();
103
+ export function resolveDeviceCompliance(devMode: boolean = false): DeviceComplianceResult {
104
+ const jailMonkey = loadJailMonkey();
37
105
 
38
- if (!JailMonkey) {
106
+ if (!jailMonkey) {
39
107
  if (devMode) {
40
108
  console.log(
41
- "[Glomo-RN-SDK] jail-monkey not available. Device compliance check skipped. " +
42
- "Install jail-monkey for enhanced security compliance."
109
+ `[Glomo-RN-SDK] ${COMPLIANCE_PACKAGE} not available. Device compliance could not be determined.`
43
110
  );
44
111
  }
45
-
46
- // Check unavailable - allowing checkout to proceed
47
- return null;
112
+ return { compromised: null, source: "unavailable", trippedSignals: [] };
48
113
  }
49
114
 
50
115
  try {
51
- const isCompromised = JailMonkey.isJailBroken();
52
- if (devMode) {
53
- console.log(
54
- `[Glomo-RN-SDK] Device compliance check: ${isCompromised ? "FAILED (device compromised)" : "PASSED"}`
116
+ const breakdown = jailMonkey.androidRootedDetectionMethods;
117
+ // Read as an open map: the checks are iterated, and a jail-monkey that adds one should have
118
+ // it counted rather than ignored for being absent from a hardcoded list
119
+ const rootBeer = breakdown?.rootBeer as Record<string, boolean> | undefined;
120
+
121
+ if (rootBeer && typeof rootBeer === "object") {
122
+ const trippedSignals = Object.keys(rootBeer).filter(
123
+ (signal) => rootBeer[signal] === true && !IGNORED_ROOT_SIGNALS.includes(signal)
55
124
  );
125
+
126
+ // jail-monkey's own non-rootBeer signal sits beside the map rather than inside it
127
+ if (breakdown?.jailMonkey === true) {
128
+ trippedSignals.push("jailMonkey");
129
+ }
130
+
131
+ if (devMode) {
132
+ console.log(
133
+ `[Glomo-RN-SDK] Device compliance (decomposed): ${
134
+ trippedSignals.length > 0 ? `FAILED via ${trippedSignals.join(", ")}` : "PASSED"
135
+ }`
136
+ );
137
+ }
138
+ return { compromised: trippedSignals.length > 0, source: "decomposed", trippedSignals };
56
139
  }
57
- return isCompromised;
140
+
141
+ const compromised = jailMonkey.isJailBroken();
142
+ if (devMode) {
143
+ console.log(`[Glomo-RN-SDK] Device compliance (aggregate): ${compromised ? "FAILED" : "PASSED"}`);
144
+ }
145
+ return {
146
+ compromised,
147
+ source: "aggregate",
148
+ trippedSignals: compromised ? ["isJailBroken"] : [],
149
+ };
58
150
  } catch (error) {
59
151
  if (devMode) {
60
- const errorMessage = error instanceof Error ? error.message : String(error);
61
- console.error("[Glomo-RN-SDK] Error checking device compliance:", errorMessage);
152
+ const message = error instanceof Error ? error.message : String(error);
153
+ console.error("[Glomo-RN-SDK] Error checking device compliance:", message);
62
154
  }
63
- // On error, allowing checkout to proceed (fail open strategy)
64
- return null;
155
+ // Unknown, not clean. The caller decides what to do with that, and on a live key it blocks.
156
+ return { compromised: null, source: "unavailable", trippedSignals: [] };
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Whether a checkout must be refused on this device.
162
+ *
163
+ * **Fails closed on a live key**: unknown blocks, because on RN unknown overwhelmingly means the
164
+ * merchant did not install `jail-monkey` rather than that a platform probe failed. That is why the
165
+ * package stopped being an optional peer dependency in v5 - a dependency cannot be optional and
166
+ * load-bearing at once, and an install-time warning beats a silent runtime outage.
167
+ *
168
+ * **Never gates mock or test traffic.** Measured, never blocked - matching the Flutter SDK's
169
+ * `enforced` model, and so QA on a rooted handset or an emulator image stays possible.
170
+ */
171
+ export function blocksCheckout(result: DeviceComplianceResult, publicKey?: string): boolean {
172
+ if (isMockModeKey(publicKey)) {
173
+ return false;
65
174
  }
175
+ return result.compromised !== false;
66
176
  }
67
177
 
68
178
  /**
69
- * Checks if compliance checking is possible in the current environment
179
+ * Whether the device compliance check could run at all
70
180
  *
71
- * @returns true if peerDependencies for compliance checking are installed and available
181
+ * @returns true if jail-monkey is installed and its native module is linked
72
182
  */
73
183
  export function isComplianceCheckAvailable(): boolean {
74
- return getJailMonkey() !== null;
184
+ return loadJailMonkey() !== null;
75
185
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Runtime allowlists for what a callback payload may contribute to telemetry
3
+ *
4
+ * **Every tracker that reports a payload takes one of these, never the payload itself.**
5
+ *
6
+ * The rule exists because TypeScript types are erased. A `Pick<>`, a cast or a projection
7
+ * *interface* removes nothing from the object at runtime - the value still carries every field, and
8
+ * `client.post("/track", payload)` serialises all of them. So "pass the payload, constrain it with a
9
+ * type" compiles cleanly, reviews cleanly, and ships `signature` to Segment on every payment. A
10
+ * signature is a bearer credential.
11
+ *
12
+ * Constructing the outbound object field by field is what actually enforces the allowlist, and
13
+ * keeping it here rather than at each call site is what keeps the callback and the event assembled
14
+ * from one source, so a field added to a payload cannot reach an event unless someone adds it here
15
+ * deliberately.
16
+ */
17
+
18
+ import { type GlomoCheckoutPayload, type GlomoTerminationPayload } from "../types/checkout";
19
+
20
+ /** What a payment payload may say about itself */
21
+ export interface PaymentEventData {
22
+ orderId: string;
23
+ paymentId?: string;
24
+ }
25
+
26
+ /**
27
+ * Projection of a success or failure payload.
28
+ *
29
+ * `signature` is excluded and must stay excluded - it is the field this whole module exists for.
30
+ */
31
+ export function toPaymentEventData(payload: GlomoCheckoutPayload): PaymentEventData {
32
+ return { orderId: payload.orderId, paymentId: payload.paymentId };
33
+ }
34
+
35
+ /** What a termination payload may say about itself */
36
+ export interface TerminationEventData {
37
+ source: string;
38
+ }
39
+
40
+ /** Projection of a termination payload. Everything it carries today is safe; that is not a licence. */
41
+ export function toTerminationEventData(payload: GlomoTerminationPayload): TerminationEventData {
42
+ return { source: payload.source };
43
+ }
44
+
45
+ /** What an asynchronous journey payload may say about itself */
46
+ export interface JourneyEventData {
47
+ journeyType: string;
48
+ journeyStatus?: string;
49
+
50
+ /**
51
+ * Whether the page sent bank details, not what they were.
52
+ *
53
+ * `senderAccountNumber` is a customer bank account number and `transactionReference` identifies
54
+ * a specific transfer; neither belongs in analytics. Presence is what triage actually needs - it
55
+ * separates "the page reported a transfer without details" from "the SDK dropped them".
56
+ */
57
+ hasSenderAccountNumber: boolean;
58
+ hasTransactionReference: boolean;
59
+ }
60
+
61
+ /** Projection of an asynchronous journey payload */
62
+ export function toJourneyEventData(payload: {
63
+ journeyType: string;
64
+ status?: string;
65
+ senderAccountNumber?: string;
66
+ transactionReference?: string;
67
+ }): JourneyEventData {
68
+ return {
69
+ journeyType: payload.journeyType,
70
+ journeyStatus: payload.status,
71
+ hasSenderAccountNumber: Boolean(payload.senderAccountNumber),
72
+ hasTransactionReference: Boolean(payload.transactionReference),
73
+ };
74
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Shapes what a saved or picked file may say about itself in telemetry
3
+ *
4
+ * Extension and byte count only - never the file name, which is the user's data
5
+ * (`passport_A1234567.pdf`) where these two are not.
6
+ *
7
+ * Values are sentinels rather than nulls so an event always carries the same shape and a missing
8
+ * value is something to filter on. Ported from the Flutter SDK's `FileMetadata` so the two report
9
+ * the same vocabulary on the same events.
10
+ */
11
+
12
+ /**
13
+ * Length could not be read. Negative because `0` is legitimate: a zero-byte file is a real failure
14
+ * that would otherwise look like an unexplained bank rejection.
15
+ */
16
+ export const SIZE_UNAVAILABLE = -1;
17
+
18
+ /** The file had no extension. User behaviour, not an error. */
19
+ export const EXTENSION_NONE = "FILE_WITH_NO_EXTENSION";
20
+
21
+ /**
22
+ * The tail was rejected: too long, or not purely alphanumeric. In the field this is ordinary user
23
+ * naming (`scan.jpeg (1)`), not an SDK fault. Do not alert on it.
24
+ */
25
+ export const EXTENSION_EXTRACTION_FAILED = "EXTRACTION_FAILED";
26
+
27
+ /**
28
+ * A well-formed extension that is not one we know. Reports that something novel was handled without
29
+ * carrying the value, so a new format is still visible as an event.
30
+ */
31
+ export const EXTENSION_UNKNOWN = "UNKNOWN_EXTENSION";
32
+
33
+ /** First of two gates. Rejects a long tail early; KNOWN_EXTENSIONS is what actually bounds this. */
34
+ const MAX_EXTENSION_LENGTH = 5;
35
+
36
+ /**
37
+ * Extensions reported verbatim, and the MIME type each one maps to.
38
+ *
39
+ * Anything outside this map becomes a sentinel, so no part of a filename can reach telemetry by
40
+ * being short enough to look plausible. The MIME side exists because Android's save dialog is an
41
+ * `ACTION_CREATE_DOCUMENT` intent that wants a type: deriving it here rather than forwarding the
42
+ * `mimeType` the page sends keeps untrusted page input out of an Intent, and is the same call the
43
+ * Flutter SDK makes when it declines to forward that field to `file_picker`.
44
+ */
45
+ const KNOWN_EXTENSIONS: Record<string, string> = {
46
+ pdf: "application/pdf",
47
+ epdf: "application/pdf",
48
+ jpg: "image/jpeg",
49
+ jpeg: "image/jpeg",
50
+ png: "image/png",
51
+ gif: "image/gif",
52
+ webp: "image/webp",
53
+ bmp: "image/bmp",
54
+ tiff: "image/tiff",
55
+ tif: "image/tiff",
56
+ heic: "image/heic",
57
+ heif: "image/heif",
58
+ jfif: "image/jpeg",
59
+ pjpeg: "image/jpeg",
60
+ doc: "application/msword",
61
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
62
+ xls: "application/vnd.ms-excel",
63
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
64
+ csv: "text/csv",
65
+ txt: "text/plain",
66
+ rtf: "application/rtf",
67
+ odt: "application/vnd.oasis.opendocument.text",
68
+ zip: "application/zip",
69
+ };
70
+
71
+ /** Hoisted - extensionOf runs on every save. */
72
+ const ALPHANUMERIC = /^[a-z0-9]+$/;
73
+
74
+ /**
75
+ * Lowercased extension of a path, or a sentinel.
76
+ *
77
+ * Not `split(".").pop()`, which returns the whole filename when there is no dot - shipping exactly
78
+ * what this exists to keep out.
79
+ */
80
+ export function extensionOf(path?: string | null): string {
81
+ if (!path) {
82
+ return EXTENSION_EXTRACTION_FAILED;
83
+ }
84
+
85
+ // A staging directory can carry dots, so the basename is isolated before looking for one
86
+ const name = path.split("/").pop() ?? "";
87
+ if (name.length === 0) {
88
+ return EXTENSION_EXTRACTION_FAILED;
89
+ }
90
+
91
+ const dot = name.lastIndexOf(".");
92
+ // `dot === 0` is a dotfile (`.bank_statement`), not an extension
93
+ if (dot <= 0 || dot === name.length - 1) {
94
+ return EXTENSION_NONE;
95
+ }
96
+
97
+ /**
98
+ * Rejected, not stripped. Stripping separators first let a 15-character filename tail collapse
99
+ * into a plausible 8-character extension (`aadhaar.9876-5432` -> `98765432`), which is filename
100
+ * content on a KYC upload. A real extension has no separators to remove.
101
+ */
102
+ const raw = name.slice(dot + 1).toLowerCase();
103
+ if (raw.length === 0 || raw.length > MAX_EXTENSION_LENGTH || !ALPHANUMERIC.test(raw)) {
104
+ return EXTENSION_EXTRACTION_FAILED;
105
+ }
106
+
107
+ return raw in KNOWN_EXTENSIONS ? raw : EXTENSION_UNKNOWN;
108
+ }
109
+
110
+ /**
111
+ * The MIME type to declare for a file, from the extension extensionOf resolved.
112
+ *
113
+ * Falls back to the generic type for every sentinel: an unrecognised extension is exactly the case
114
+ * where a guess would be wrong, and Android will still let the user save with a generic type.
115
+ */
116
+ export function mimeTypeFor(fileExtension: string): string {
117
+ return KNOWN_EXTENSIONS[fileExtension] ?? "application/octet-stream";
118
+ }
119
+
120
+ /**
121
+ * Normalises a raw HTML `accept` attribute for telemetry.
122
+ *
123
+ * The injected JS reads the attribute verbatim (`".pdf, .jpeg"`) where a native layer may hand over
124
+ * an already-parsed list (`".pdf,.jpeg"`), so without this the request event and anything reported
125
+ * later for the same click will not join on it.
126
+ *
127
+ * Never used to decide what may be picked - the bank owns that.
128
+ */
129
+ export function normalizeAcceptTypes(acceptTypes?: string | null): string | undefined {
130
+ if (!acceptTypes || acceptTypes.trim().length === 0) {
131
+ return undefined;
132
+ }
133
+ const parts = acceptTypes
134
+ .toLowerCase()
135
+ .split(",")
136
+ .map((part) => part.trim())
137
+ .filter((part) => part.length > 0)
138
+ .sort();
139
+ return parts.length === 0 ? undefined : parts.join(",");
140
+ }
@@ -0,0 +1,41 @@
1
+ /** Binds one checkout attempt's telemetry context for as long as the component is mounted */
2
+
3
+ import { useEffect, useRef } from "react";
4
+
5
+ import { bindCheckoutStatus, releaseSdkSession, setSdkSession } from "./analytics";
6
+
7
+ /** Distinguishes two mounts in the same process without reaching for a uuid dependency */
8
+ let mountCounter = 0;
9
+
10
+ /**
11
+ * Mints a session id and binds a checkout-status getter, both for the mounted lifetime.
12
+ *
13
+ * Deliberately a hook rather than something `start()` calls, so a merchant who retries produces two
14
+ * `start_attempted` events inside one session instead of two unrelated sessions.
15
+ *
16
+ * The status getter is held in a ref and re-read on every event, so a status that changes mid-session
17
+ * reaches later events without anything re-binding.
18
+ */
19
+ export function useSdkSession(readStatus: () => string): void {
20
+ const readStatusRef = useRef(readStatus);
21
+ readStatusRef.current = readStatus;
22
+
23
+ useEffect(() => {
24
+ const sessionId = `sess-${Date.now().toString(36)}-${(++mountCounter).toString(36)}-${Math.random()
25
+ .toString(36)
26
+ .slice(2, 8)}`;
27
+
28
+ setSdkSession(sessionId);
29
+ bindCheckoutStatus(() => readStatusRef.current());
30
+
31
+ return () => {
32
+ /**
33
+ * Cleared rather than left dangling, so an event from an unmounted checkout carries
34
+ * neither - but only if this mount's session is still the active one. A merchant with
35
+ * two checkouts alive would otherwise have the one unmounting silence the one still
36
+ * taking a payment.
37
+ */
38
+ releaseSdkSession(sessionId);
39
+ };
40
+ }, []);
41
+ }
@@ -19,6 +19,28 @@ export function isValidPublicKey(publicKey: string): boolean {
19
19
  );
20
20
  }
21
21
 
22
+ /**
23
+ * Prefixes that run the checkout against mocks instead of taking a live payment.
24
+ *
25
+ * `isValidPublicKey` has always accepted all three prefixes while every mock-mode check tested only
26
+ * `test_`, so a `mock_` key passed validation and then had `mode=live` built into its checkout URL -
27
+ * a mock key taking a real payment. Declared once here so the two cannot drift again.
28
+ */
29
+ const MOCK_MODE_PREFIXES = ["test_", "mock_"];
30
+
31
+ /**
32
+ * Whether this public key should run in mock mode.
33
+ *
34
+ * Case-insensitive, matching what the call sites already did.
35
+ */
36
+ export function isMockModeKey(publicKey?: string): boolean {
37
+ const normalized = publicKey?.toLowerCase();
38
+ if (!normalized) {
39
+ return false;
40
+ }
41
+ return MOCK_MODE_PREFIXES.some((prefix) => normalized.startsWith(prefix));
42
+ }
43
+
22
44
  /**
23
45
  * Validates that an order ID has the correct format
24
46
  * @param orderId - The order ID to validate
@@ -46,27 +68,65 @@ export function isValidUrl(url: string): boolean {
46
68
  return url.startsWith("http://") || url.startsWith("https://");
47
69
  }
48
70
 
71
+ /**
72
+ * The fields a payment payload may be missing and still be usable
73
+ *
74
+ * `paymentId` and `signature` are resolved by the checkout backend, and a page can report a
75
+ * terminal outcome before that has happened - open banking completes on the bank's side first.
76
+ * Requiring them meant the SDK dropped a real payment outcome on the floor rather than reporting an
77
+ * incomplete one, so the merchant learned nothing at all.
78
+ */
79
+ const OPTIONAL_PAYMENT_FIELDS = ["paymentId", "signature"] as const;
80
+
49
81
  /**
50
82
  * Validates the payment payload structure
83
+ *
84
+ * `orderId` is the only field the SDK can route on and the only one required. The other two are
85
+ * reported when present and named in telemetry when absent - see missingPaymentFields.
86
+ *
87
+ * Relaxed in v5. Before this, a payload missing either optional field failed here and the whole
88
+ * outcome was dropped behind a devMode-only log: no callback, no telemetry, no trace in a release
89
+ * build. `strict: true` in tsconfig is what makes the looser shape safe for merchants - an optional
90
+ * field cannot reach anything expecting a `string` without a narrowing check, so this lands as a
91
+ * compile error rather than a runtime surprise.
92
+ *
93
+ * An empty `orderId` is accepted, and must stay accepted. Open banking and pay-via-bank post
94
+ * `payment.success` with empty fields when the payment completes on the bank's side before the
95
+ * checkout backend has resolved ids - so rejecting `""` here would drop a payment that actually
96
+ * succeeded, which is the exact failure the widening above exists to remove. v4 carried the same
97
+ * rule in a comment; a length check briefly reintroduced the bug in v5 before review caught it.
98
+ * Type is the only thing checked: a non-string `orderId` is malformed, an empty one is early.
99
+ *
51
100
  * @param payload - The payload to validate
52
- * @returns true if valid, false otherwise
101
+ * @returns true if the payload carries a usable orderId
53
102
  */
54
103
  export function isValidPaymentPayload(payload: unknown): payload is {
55
104
  orderId: string;
56
- paymentId: string;
57
- signature: string;
105
+ paymentId?: string;
106
+ signature?: string;
58
107
  } {
59
108
  if (!payload || typeof payload !== "object") {
60
109
  return false;
61
110
  }
62
111
  const p = payload as Record<string, unknown>;
63
- /**
64
- * Accept payloads where orderId, paymentId, and signature are strings.
65
- * Empty strings are allowed because some flows (e.g. open banking / pay via bank)
66
- * send payment.success with empty fields when the payment completes on the
67
- * bank's side before the checkout backend has resolved ids.
68
- */
69
- return typeof p.orderId === "string" && typeof p.paymentId === "string" && typeof p.signature === "string";
112
+
113
+ if (typeof p.orderId !== "string") {
114
+ return false;
115
+ }
116
+
117
+ // Present-but-wrong-type is a malformed payload, unlike absent, which is merely incomplete
118
+ return OPTIONAL_PAYMENT_FIELDS.every((field) => p[field] === undefined || typeof p[field] === "string");
119
+ }
120
+
121
+ /**
122
+ * Which optional fields a valid payload did not carry.
123
+ *
124
+ * Empty for a complete payload. Treats the empty string as missing: the page sends `""` rather than
125
+ * omitting the key when the backend has not resolved an id, and the two mean the same thing to
126
+ * anyone reading the event.
127
+ */
128
+ export function missingPaymentFields(payload: { paymentId?: string; signature?: string }): string[] {
129
+ return OPTIONAL_PAYMENT_FIELDS.filter((field) => !payload[field]);
70
130
  }
71
131
 
72
132
  /**
@@ -0,0 +1,86 @@
1
+ /** Shared WebView configuration for every GlomoPay checkout WebView */
2
+
3
+ import { type WebViewProps } from "react-native-webview";
4
+
5
+ import { type WebViewPermissionRequest } from "./use-permission-request";
6
+
7
+ /**
8
+ * react-native-webview's props plus the Android-only permission handler, which is
9
+ * missing from its type definitions. Declaring it here removes the @ts-expect-error
10
+ * each call site used to carry.
11
+ */
12
+ export type CapabilityWebViewProps = Partial<WebViewProps> & {
13
+ onPermissionRequest?: (request: WebViewPermissionRequest) => void;
14
+ };
15
+
16
+ /** The options for buildCapabilityProps */
17
+ export interface CapabilityPropsOptions {
18
+ devMode?: boolean;
19
+
20
+ /** From usePermissionRequest. Omit only for WebViews that render our own content. */
21
+ onPermissionRequest?: (request: WebViewPermissionRequest) => void;
22
+ }
23
+
24
+ /**
25
+ * Configuration shared by every WebView the SDK renders, regardless of flow.
26
+ *
27
+ * Only configuration belongs here - things that describe how a WebView behaves. Per-WebView
28
+ * concerns (source, refs, message handlers, injected scripts, styles) stay at the call site,
29
+ * and defensive patches against a bank's markup stay in the flow injection script, since the
30
+ * WebViews that render our own pages have no third-party markup to defend against.
31
+ */
32
+ export function buildCapabilityProps({
33
+ devMode = false,
34
+ onPermissionRequest,
35
+ }: CapabilityPropsOptions): CapabilityWebViewProps {
36
+ return {
37
+ webviewDebuggingEnabled: __DEV__ && devMode,
38
+ javaScriptEnabled: true,
39
+ domStorageEnabled: true,
40
+ javaScriptCanOpenWindowsAutomatically: true,
41
+ setSupportMultipleWindows: true,
42
+ incognito: true,
43
+ cacheEnabled: false,
44
+ cacheMode: "LOAD_NO_CACHE",
45
+ pullToRefreshEnabled: false,
46
+ limitsNavigationsToAppBoundDomains: false,
47
+ menuItems: [],
48
+ showsHorizontalScrollIndicator: false,
49
+ showsVerticalScrollIndicator: false,
50
+ mixedContentMode: "always",
51
+ thirdPartyCookiesEnabled: true,
52
+ sharedCookiesEnabled: true,
53
+ allowsInlineMediaPlayback: true,
54
+ mediaPlaybackRequiresUserAction: false,
55
+
56
+ /**
57
+ * Camera for bank authentication. iOS raises its own prompt; Android routes through
58
+ * onPermissionRequest below.
59
+ */
60
+ mediaCapturePermissionGrantType: "prompt",
61
+ onPermissionRequest,
62
+
63
+ /**
64
+ * File access props.
65
+ *
66
+ * These only apply to JavaScript running inside a file:// document, and no file://
67
+ * document can load here - neither factory sets originWhitelist, so
68
+ * react-native-webview's default of ['http://*', 'https://*'] applies and a file://
69
+ * navigation is never taken. Every WebView loads remote https under incognito. So
70
+ * these are inert, and kept for uniformity across flows rather than because anything
71
+ * needs them.
72
+ *
73
+ * Note allowFileAccess is not inert in the same way: rnw defaults it to false on
74
+ * Android, so we are the ones turning it on. Deliberate, and safe only because of the
75
+ * originWhitelist default above.
76
+ *
77
+ * If originWhitelist is ever set to admit file://, all three become live and this
78
+ * needs revisiting.
79
+ */
80
+ allowFileAccess: true,
81
+ allowUniversalAccessFromFileURLs: true,
82
+ allowFileAccessFromFileURLs: true,
83
+
84
+ androidLayerType: "hardware",
85
+ };
86
+ }