@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/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,4 @@
1
+ // Single source of truth for the SDK version. Surfaced in the WebView
2
+ // userAgent suffix (never with any credential appended) for abuse
3
+ // throttling and analytics. Keep in sync with package.json "version".
4
+ export const SDK_VERSION = '0.1.0';
@@ -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
+ }