@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.
- package/CHANGELOG.md +29 -0
- package/MIGRATION.md +112 -0
- package/README.md +173 -36
- package/lib/bridge/file-input-handler.d.ts +25 -0
- package/lib/bridge/file-input-handler.d.ts.map +1 -0
- package/lib/bridge/file-input-handler.js +38 -0
- package/lib/bridge/file-save-handler.d.ts +32 -0
- package/lib/bridge/file-save-handler.d.ts.map +1 -0
- package/lib/bridge/file-save-handler.js +92 -0
- package/lib/bridge/message-router.d.ts +44 -0
- package/lib/bridge/message-router.d.ts.map +1 -0
- package/lib/bridge/message-router.js +48 -0
- package/lib/bridge/payment-payload.d.ts +18 -0
- package/lib/bridge/payment-payload.d.ts.map +1 -0
- package/lib/bridge/payment-payload.js +31 -0
- package/lib/capabilities/file-save.d.ts +40 -0
- package/lib/capabilities/file-save.d.ts.map +1 -0
- package/lib/capabilities/file-save.js +101 -0
- package/lib/capabilities/index.d.ts +22 -0
- package/lib/capabilities/index.d.ts.map +1 -0
- package/lib/capabilities/index.js +34 -0
- package/lib/capabilities/use-capability-reporting.d.ts +24 -0
- package/lib/capabilities/use-capability-reporting.d.ts.map +1 -0
- package/lib/capabilities/use-capability-reporting.js +44 -0
- package/lib/config/base.d.ts +12 -0
- package/lib/config/base.d.ts.map +1 -1
- package/lib/config/base.js +13 -1
- package/lib/glomo-checkout.d.ts +9 -1
- package/lib/glomo-checkout.d.ts.map +1 -1
- package/lib/glomo-checkout.js +14 -5
- package/lib/glomo-lrs-checkout.d.ts +3 -0
- package/lib/glomo-lrs-checkout.d.ts.map +1 -1
- package/lib/glomo-lrs-checkout.js +34 -11
- package/lib/glomo-standard-checkout.d.ts +4 -1
- package/lib/glomo-standard-checkout.d.ts.map +1 -1
- package/lib/glomo-standard-checkout.js +21 -13
- package/lib/glomo-subscriptions-checkout.d.ts.map +1 -1
- package/lib/glomo-subscriptions-checkout.js +1 -1
- package/lib/index.d.ts +2 -2
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +4 -3
- package/lib/injections/index.d.ts +24 -0
- package/lib/injections/index.d.ts.map +1 -1
- package/lib/injections/index.js +41 -0
- package/lib/injections/webview-flow.injection.d.ts.map +1 -1
- package/lib/injections/webview-flow.injection.js +76 -0
- package/lib/injections/webview-main.injection.d.ts.map +1 -1
- package/lib/injections/webview-main.injection.js +31 -0
- package/lib/injections/webview-standard.injection.d.ts.map +1 -1
- package/lib/injections/webview-standard.injection.js +31 -0
- package/lib/services/file-saver.d.ts +109 -0
- package/lib/services/file-saver.d.ts.map +1 -0
- package/lib/services/file-saver.js +416 -0
- package/lib/types/checkout.d.ts +67 -6
- package/lib/types/checkout.d.ts.map +1 -1
- package/lib/types/checkout.js +24 -2
- package/lib/types/standard-checkout.d.ts +6 -6
- package/lib/types/standard-checkout.d.ts.map +1 -1
- package/lib/types/subscriptions-checkout.d.ts +5 -3
- package/lib/types/subscriptions-checkout.d.ts.map +1 -1
- package/lib/use-glomo-checkout.d.ts +2 -2
- package/lib/use-glomo-checkout.d.ts.map +1 -1
- package/lib/use-glomo-checkout.js +25 -4
- package/lib/use-lrs-checkout.d.ts +18 -6
- package/lib/use-lrs-checkout.d.ts.map +1 -1
- package/lib/use-lrs-checkout.js +330 -171
- package/lib/use-standard-checkout.d.ts +18 -13
- package/lib/use-standard-checkout.d.ts.map +1 -1
- package/lib/use-standard-checkout.js +282 -206
- package/lib/utils/analytics.d.ts +177 -51
- package/lib/utils/analytics.d.ts.map +1 -1
- package/lib/utils/analytics.js +299 -81
- package/lib/utils/device-compliance.d.ts +57 -12
- package/lib/utils/device-compliance.d.ts.map +1 -1
- package/lib/utils/device-compliance.js +101 -37
- package/lib/utils/event-projections.d.ts +56 -0
- package/lib/utils/event-projections.d.ts.map +1 -0
- package/lib/utils/event-projections.js +42 -0
- package/lib/utils/file-metadata.d.ts +52 -0
- package/lib/utils/file-metadata.d.ts.map +1 -0
- package/lib/utils/file-metadata.js +134 -0
- package/lib/utils/use-sdk-session.d.ts +12 -0
- package/lib/utils/use-sdk-session.d.ts.map +1 -0
- package/lib/utils/use-sdk-session.js +37 -0
- package/lib/utils/validation.d.ts +37 -3
- package/lib/utils/validation.d.ts.map +1 -1
- package/lib/utils/validation.js +64 -8
- package/lib/webview/capability-props.d.ts +27 -0
- package/lib/webview/capability-props.d.ts.map +1 -0
- package/lib/webview/capability-props.js +61 -0
- package/lib/webview/use-permission-request.d.ts +33 -0
- package/lib/webview/use-permission-request.d.ts.map +1 -0
- package/lib/webview/use-permission-request.js +89 -0
- package/package.json +13 -3
- package/src/bridge/file-input-handler.ts +66 -0
- package/src/bridge/file-save-handler.ts +141 -0
- package/src/bridge/message-router.ts +92 -0
- package/src/bridge/payment-payload.ts +31 -0
- package/src/capabilities/file-save.ts +124 -0
- package/src/capabilities/index.ts +34 -0
- package/src/capabilities/use-capability-reporting.ts +55 -0
- package/src/config/base.ts +13 -0
- package/src/glomo-checkout.tsx +28 -11
- package/src/glomo-lrs-checkout.tsx +49 -27
- package/src/glomo-standard-checkout.tsx +33 -34
- package/src/glomo-subscriptions-checkout.tsx +1 -0
- package/src/index.ts +4 -2
- package/src/injections/index.ts +43 -0
- package/src/injections/webview-flow.injection.ts +76 -0
- package/src/injections/webview-main.injection.ts +31 -0
- package/src/injections/webview-standard.injection.ts +31 -0
- package/src/services/file-saver.ts +497 -0
- package/src/types/checkout.ts +72 -6
- package/src/types/standard-checkout.ts +6 -6
- package/src/types/subscriptions-checkout.ts +9 -3
- package/src/use-glomo-checkout.tsx +32 -7
- package/src/use-lrs-checkout.tsx +684 -366
- package/src/use-standard-checkout.tsx +731 -465
- package/src/utils/analytics.ts +422 -117
- package/src/utils/device-compliance.ts +148 -38
- package/src/utils/event-projections.ts +74 -0
- package/src/utils/file-metadata.ts +140 -0
- package/src/utils/use-sdk-session.ts +41 -0
- package/src/utils/validation.ts +70 -10
- package/src/webview/capability-props.ts +86 -0
- package/src/webview/use-permission-request.ts +114 -0
- package/src/jail-monkey.d.ts +0 -19
|
@@ -1,75 +1,185 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Device compliance
|
|
2
|
+
* Device compliance for the GlomoPay RN SDK
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
81
|
+
const jailMonkeyModule: JailMonkeyModule | null = (() => {
|
|
15
82
|
try {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
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
|
|
36
|
-
const
|
|
103
|
+
export function resolveDeviceCompliance(devMode: boolean = false): DeviceComplianceResult {
|
|
104
|
+
const jailMonkey = loadJailMonkey();
|
|
37
105
|
|
|
38
|
-
if (!
|
|
106
|
+
if (!jailMonkey) {
|
|
39
107
|
if (devMode) {
|
|
40
108
|
console.log(
|
|
41
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
61
|
-
console.error("[Glomo-RN-SDK] Error checking device compliance:",
|
|
152
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
153
|
+
console.error("[Glomo-RN-SDK] Error checking device compliance:", message);
|
|
62
154
|
}
|
|
63
|
-
//
|
|
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
|
-
*
|
|
179
|
+
* Whether the device compliance check could run at all
|
|
70
180
|
*
|
|
71
|
-
* @returns true if
|
|
181
|
+
* @returns true if jail-monkey is installed and its native module is linked
|
|
72
182
|
*/
|
|
73
183
|
export function isComplianceCheckAvailable(): boolean {
|
|
74
|
-
return
|
|
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
|
+
}
|
package/src/utils/validation.ts
CHANGED
|
@@ -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
|
|
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
|
|
57
|
-
signature
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return
|
|
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
|
+
}
|