@gbitx/pay-react-native 0.1.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 +11 -0
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/SECURITY.md +28 -0
- package/dist/index.cjs +693 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.mts +98 -0
- package/dist/index.d.ts +98 -0
- package/dist/index.mjs +685 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +79 -0
- package/src/CheckoutModal.tsx +334 -0
- package/src/GbitXPay.ts +16 -0
- package/src/GbitXPayProvider.tsx +29 -0
- package/src/config.ts +219 -0
- package/src/controller.ts +100 -0
- package/src/errors.ts +89 -0
- package/src/index.ts +33 -0
- package/src/messageHandler.ts +124 -0
- package/src/redact.ts +58 -0
- package/src/types.ts +99 -0
- package/src/useGbitXPay.ts +16 -0
- package/src/validation.ts +78 -0
- package/src/version.ts +4 -0
- package/src/webviewPolicy.ts +55 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Public type surface for @gbitx/pay-react-native.
|
|
2
|
+
//
|
|
3
|
+
// This module is deliberately free of any react-native import so the core
|
|
4
|
+
// (types + validation + config + message parsing) stays testable in plain
|
|
5
|
+
// Node and safe to import anywhere.
|
|
6
|
+
|
|
7
|
+
export type Environment = 'live' | 'test';
|
|
8
|
+
|
|
9
|
+
// Mirrors the server PaymentStatus enum (gatewayService prisma schema).
|
|
10
|
+
export type PaymentStatus =
|
|
11
|
+
| 'PENDING'
|
|
12
|
+
| 'AWAITING_PAYMENT'
|
|
13
|
+
| 'UNDERPAID'
|
|
14
|
+
| 'CONFIRMING'
|
|
15
|
+
| 'CONFIRMED'
|
|
16
|
+
| 'EXPIRED'
|
|
17
|
+
| 'FAILED'
|
|
18
|
+
| 'CANCELLED';
|
|
19
|
+
|
|
20
|
+
// EXACTLY the fields the hosted checkout emits via serializePayment
|
|
21
|
+
// (gbitxpay-frontend/src/pages/PaymentPage.jsx). `address`, `txHash`,
|
|
22
|
+
// `cancelToken`, and `externalRef` are intentionally absent: the page
|
|
23
|
+
// strips them, and an app must never believe it can read on-chain or
|
|
24
|
+
// private fields client-side. Amounts arrive as decimal-safe strings.
|
|
25
|
+
export interface SanitizedPayment {
|
|
26
|
+
id: string;
|
|
27
|
+
status: PaymentStatus;
|
|
28
|
+
amount: string;
|
|
29
|
+
currency: string;
|
|
30
|
+
crypto: string | null;
|
|
31
|
+
network: string | null;
|
|
32
|
+
amountCrypto: string | null;
|
|
33
|
+
amountPaid: string | null;
|
|
34
|
+
mode: 'LIVE' | 'SANDBOX';
|
|
35
|
+
confirmedAt: string | null;
|
|
36
|
+
expiresAt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Why a checkout ended without a confirmation. UX/analytics only.
|
|
40
|
+
export type CancelReason = 'user_closed' | 'dismissed' | 'back_button' | 'load_failed';
|
|
41
|
+
export type ExpiredReason = 'expired' | 'client_backstop';
|
|
42
|
+
|
|
43
|
+
// The terminal outcome of a checkout session. This is NOT an error — a
|
|
44
|
+
// cancelled or expired payment is a normal, expected result. `payment` is
|
|
45
|
+
// populated in practice for confirmed checkouts (the page always sends it)
|
|
46
|
+
// but is typed nullable because a bridge message is device-controllable and
|
|
47
|
+
// UX-only: fulfilment is driven by the signed server webhook, never by this.
|
|
48
|
+
export type PaymentResult =
|
|
49
|
+
| { status: 'confirmed'; payment: SanitizedPayment | null }
|
|
50
|
+
| { status: 'cancelled'; reason?: CancelReason; payment?: SanitizedPayment | null }
|
|
51
|
+
| { status: 'expired'; reason?: ExpiredReason; payment?: SanitizedPayment | null }
|
|
52
|
+
| { status: 'failed'; payment?: SanitizedPayment | null };
|
|
53
|
+
|
|
54
|
+
export interface ConfigureOptions {
|
|
55
|
+
// The merchant's PUBLISHABLE key (pk_live_… / pk_test_…). Safe to embed.
|
|
56
|
+
publishableKey: string;
|
|
57
|
+
// Optional assertion. If set it must match BOTH the key prefix and the
|
|
58
|
+
// server's reported environment, else configure() rejects with
|
|
59
|
+
// 'environment_mismatch'.
|
|
60
|
+
environment?: Environment;
|
|
61
|
+
// Bounded timeout (ms) for the GET /v1/sdk/config validation call.
|
|
62
|
+
// Default 10_000.
|
|
63
|
+
timeoutMs?: number;
|
|
64
|
+
// Override the GbitX API base (default https://gateway.gbitx.com). Must be
|
|
65
|
+
// https. For staging/testing only — never point this at an untrusted host.
|
|
66
|
+
apiBaseUrl?: string;
|
|
67
|
+
// Enable redacted debug logging (never prints a key/token/full URL).
|
|
68
|
+
debug?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Non-sensitive merchant info resolved at configure() time.
|
|
72
|
+
export interface MerchantInfo {
|
|
73
|
+
name: string;
|
|
74
|
+
website: string | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type CheckoutEventType =
|
|
78
|
+
| 'loaded'
|
|
79
|
+
| 'confirmed'
|
|
80
|
+
| 'cancelled'
|
|
81
|
+
| 'expired'
|
|
82
|
+
| 'failed'
|
|
83
|
+
| 'closed';
|
|
84
|
+
|
|
85
|
+
export interface CheckoutEvent {
|
|
86
|
+
type: CheckoutEventType;
|
|
87
|
+
payment?: SanitizedPayment;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface PresentOptions {
|
|
91
|
+
// From YOUR server's POST /v1/payments response (id + clientToken). The
|
|
92
|
+
// app never creates payments; your server does, with the SECRET key.
|
|
93
|
+
paymentId: string;
|
|
94
|
+
clientToken: string;
|
|
95
|
+
// Optional progress callbacks (UX only). onLoaded fires at most once when
|
|
96
|
+
// the checkout is interactive; onEvent streams every bridge lifecycle event.
|
|
97
|
+
onLoaded?: () => void;
|
|
98
|
+
onEvent?: (event: CheckoutEvent) => void;
|
|
99
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { configure } from './config';
|
|
3
|
+
import { present } from './controller';
|
|
4
|
+
import type { ConfigureOptions, PresentOptions, PaymentResult } from './types';
|
|
5
|
+
|
|
6
|
+
export interface UseGbitXPay {
|
|
7
|
+
configure: (options: ConfigureOptions) => Promise<void>;
|
|
8
|
+
present: (options: PresentOptions) => Promise<PaymentResult>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Hook flavor of the API for components that prefer it. Thin, stable wrapper
|
|
12
|
+
// over the same singleton — configure/present are module-level, so this holds
|
|
13
|
+
// no state of its own; the identity is memoized for dependency arrays.
|
|
14
|
+
export function useGbitXPay(): UseGbitXPay {
|
|
15
|
+
return useMemo(() => ({ configure, present }), []);
|
|
16
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { GbitXPayError } from './errors';
|
|
2
|
+
import type { Environment } from './types';
|
|
3
|
+
|
|
4
|
+
// A publishable key: pk_{live|test}_ + 32 random bytes hex (256-bit), exactly
|
|
5
|
+
// matching generateApiKey() in gatewayService/src/utils/crypto.js.
|
|
6
|
+
const PUBLISHABLE_KEY_RE = /^pk_(live|test)_[0-9a-f]{64}$/;
|
|
7
|
+
|
|
8
|
+
// paymentId is a cuid from Prisma (@default(cuid())): url-safe, no
|
|
9
|
+
// slash/dot/whitespace. We reject anything that could rewrite the URL
|
|
10
|
+
// path/query/fragment rather than relying on encoding alone.
|
|
11
|
+
const PAYMENT_ID_RE = /^[A-Za-z0-9_-]{8,64}$/;
|
|
12
|
+
|
|
13
|
+
// clientToken is the payment cancelToken (crypto.randomBytes(16).hex = 32 hex
|
|
14
|
+
// chars today). Kept a bounded alphanumeric charset so it can never carry
|
|
15
|
+
// control chars into the URL fragment; permissive on length for future-proofing.
|
|
16
|
+
const CLIENT_TOKEN_RE = /^[A-Za-z0-9]{16,128}$/;
|
|
17
|
+
|
|
18
|
+
// Validate the publishable key BEFORE any network call. A secret key gets its
|
|
19
|
+
// own security-flavored error so an integrator immediately pulls it out of the
|
|
20
|
+
// binary. The key value is NEVER included in an error message.
|
|
21
|
+
export function assertPublishableKey(publishableKey: unknown): { key: string; environment: Environment } {
|
|
22
|
+
if (typeof publishableKey !== 'string' || publishableKey.trim() === '') {
|
|
23
|
+
throw new GbitXPayError('invalid_key', 'A publishableKey string is required.');
|
|
24
|
+
}
|
|
25
|
+
const key = publishableKey.trim();
|
|
26
|
+
if (key.startsWith('gk_')) {
|
|
27
|
+
throw new GbitXPayError(
|
|
28
|
+
'secret_key_used',
|
|
29
|
+
'A SECRET key (gk_) was passed to the SDK. Use a publishable key (pk_) only, and remove the secret key from your app immediately.',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (!PUBLISHABLE_KEY_RE.test(key)) {
|
|
33
|
+
throw new GbitXPayError('invalid_key', 'publishableKey must look like pk_live_… or pk_test_….');
|
|
34
|
+
}
|
|
35
|
+
const environment: Environment = key.startsWith('pk_live_') ? 'live' : 'test';
|
|
36
|
+
return { key, environment };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Validate present() arguments BEFORE building any URL or presenting anything.
|
|
40
|
+
export function assertPaymentArgs(
|
|
41
|
+
paymentId: unknown,
|
|
42
|
+
clientToken: unknown,
|
|
43
|
+
): { paymentId: string; clientToken: string } {
|
|
44
|
+
const id = typeof paymentId === 'string' ? paymentId.trim() : '';
|
|
45
|
+
const token = typeof clientToken === 'string' ? clientToken.trim() : '';
|
|
46
|
+
if (!PAYMENT_ID_RE.test(id)) {
|
|
47
|
+
throw new GbitXPayError('invalid_arguments', 'paymentId is missing or malformed.');
|
|
48
|
+
}
|
|
49
|
+
if (!CLIENT_TOKEN_RE.test(token)) {
|
|
50
|
+
throw new GbitXPayError('invalid_arguments', 'clientToken is missing or malformed.');
|
|
51
|
+
}
|
|
52
|
+
return { paymentId: id, clientToken: token };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Assert an https origin and return its canonical origin form. Used for both
|
|
56
|
+
// the API base and the server-returned checkout base.
|
|
57
|
+
export function assertHttpsOrigin(raw: unknown, label: string): string {
|
|
58
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
59
|
+
throw new GbitXPayError('invalid_arguments', `${label} is required.`);
|
|
60
|
+
}
|
|
61
|
+
let u: URL;
|
|
62
|
+
try {
|
|
63
|
+
u = new URL(raw.trim());
|
|
64
|
+
} catch {
|
|
65
|
+
throw new GbitXPayError('invalid_arguments', `${label} is not a valid URL.`);
|
|
66
|
+
}
|
|
67
|
+
if (u.protocol !== 'https:') {
|
|
68
|
+
throw new GbitXPayError('invalid_arguments', `${label} must be https.`);
|
|
69
|
+
}
|
|
70
|
+
return u.origin;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Build the checkout URL. Args are pre-validated; we still encodeURIComponent
|
|
74
|
+
// as belt-and-suspenders. The clientToken rides in the fragment (#t=) exactly
|
|
75
|
+
// like POST /v1/payments returns, so the page's existing hash-capture works.
|
|
76
|
+
export function buildCheckoutUrl(checkoutOrigin: string, paymentId: string, clientToken: string): string {
|
|
77
|
+
return `${checkoutOrigin}/p/${encodeURIComponent(paymentId)}?embed=native#t=${encodeURIComponent(clientToken)}`;
|
|
78
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Pure, unit-testable WebView security policy — the exact origin decisions the
|
|
2
|
+
// SDK's trust rests on. Kept free of any react-native import so it can be
|
|
3
|
+
// tested in Node and so the decisions are auditable in isolation.
|
|
4
|
+
//
|
|
5
|
+
// EXACT origin equality everywhere (never startsWith / includes). The library's
|
|
6
|
+
// own `originWhitelist` prop is a *prefix* match (unanchored regex), so
|
|
7
|
+
// `https://pay.gbitx.com` there would also match `https://pay.gbitx.com.evil.com`
|
|
8
|
+
// and `https://pay.gbitx.company`. We therefore set originWhitelist to ['*'] in
|
|
9
|
+
// the component and make THIS module the single authoritative gate.
|
|
10
|
+
|
|
11
|
+
function parse(url: unknown): URL | null {
|
|
12
|
+
if (typeof url !== 'string' || url === '') return null;
|
|
13
|
+
try {
|
|
14
|
+
return new URL(url);
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// May a navigation load inside the WebView? In native embed mode the checkout
|
|
21
|
+
// is fully self-contained — it performs NO off-origin navigation and uses NO
|
|
22
|
+
// wallet deep-links (verified against PaymentPage.jsx: redirects are suppressed
|
|
23
|
+
// and "return to merchant" links are swapped for close buttons). So the only
|
|
24
|
+
// thing allowed to load is the pinned checkout origin over https; everything
|
|
25
|
+
// else is blocked and opened nowhere (no Linking), removing the "a compromised
|
|
26
|
+
// page force-opens an external URL / deep-link" vector entirely.
|
|
27
|
+
export function mayLoadInWebView(checkoutOrigin: string, url: unknown): boolean {
|
|
28
|
+
const u = parse(url);
|
|
29
|
+
return !!u && u.protocol === 'https:' && u.origin === checkoutOrigin;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Is a URL the pinned checkout origin over https? EXACT origin, NO path clause.
|
|
33
|
+
// Used to validate the per-message authenticated sender URL, whose shape differs
|
|
34
|
+
// by platform: on iOS react-native-webview reports the full frame URL, but on
|
|
35
|
+
// Android it reports the sender ORIGIN only (no path). Requiring a /p/ path here
|
|
36
|
+
// would reject every legitimate Android message. The /p/ path is instead asserted
|
|
37
|
+
// on the committed top-frame URL (isTrustedMessageSource) — exactly how the iOS
|
|
38
|
+
// native SDK splits frameInfo.securityOrigin (origin) from the committed URL (path).
|
|
39
|
+
//
|
|
40
|
+
// Caveat vs the native SDKs: on react-native-webview's Android path this URL is the
|
|
41
|
+
// TOP-frame value, not a per-frame authenticated sender origin, so it cannot by
|
|
42
|
+
// itself isolate a sub-frame. That residual is covered by the hosted page's CSP
|
|
43
|
+
// (frame-src 'none' blocks injected iframes) plus the page shipping no iframes.
|
|
44
|
+
export function isTrustedMessageOrigin(checkoutOrigin: string, url: unknown): boolean {
|
|
45
|
+
const u = parse(url);
|
|
46
|
+
return !!u && u.protocol === 'https:' && u.origin === checkoutOrigin;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Is a URL the pinned origin over https AND on a payment path (/p/...)? Applied to
|
|
50
|
+
// the committed top-frame URL (from onNavigationStateChange), which always carries
|
|
51
|
+
// the full path. This is the native mirror of the page's own embed-origin boundary.
|
|
52
|
+
export function isTrustedMessageSource(checkoutOrigin: string, url: unknown): boolean {
|
|
53
|
+
const u = parse(url);
|
|
54
|
+
return !!u && u.protocol === 'https:' && u.origin === checkoutOrigin && u.pathname.startsWith('/p/');
|
|
55
|
+
}
|