@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/config.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { GbitXPayError } from './errors';
|
|
2
|
+
import { assertPublishableKey, assertHttpsOrigin } from './validation';
|
|
3
|
+
import { debugLog, setDebug, redactSecrets } from './redact';
|
|
4
|
+
import type { ConfigureOptions, Environment, MerchantInfo } from './types';
|
|
5
|
+
|
|
6
|
+
// Bootstrap API base — where /v1/sdk/config lives (distinct from the checkout
|
|
7
|
+
// host returned by that endpoint). Overridable via configure({ apiBaseUrl })
|
|
8
|
+
// for staging, but must be https.
|
|
9
|
+
const DEFAULT_API_BASE = 'https://gateway.gbitx.com';
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
11
|
+
const MIN_TIMEOUT_MS = 1_000;
|
|
12
|
+
const MAX_TIMEOUT_MS = 60_000;
|
|
13
|
+
|
|
14
|
+
export interface ResolvedConfig {
|
|
15
|
+
publishableKey: string;
|
|
16
|
+
environment: Environment;
|
|
17
|
+
// Frozen https origin of the checkout host (e.g. https://pay.gbitx.com).
|
|
18
|
+
// The single root of trust for the WebView source, originWhitelist, and
|
|
19
|
+
// every message-origin check. Server-authoritative, asserted https.
|
|
20
|
+
checkoutOrigin: string;
|
|
21
|
+
merchant: MerchantInfo;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type State = 'unconfigured' | 'configuring' | 'configured' | 'configure_failed';
|
|
25
|
+
|
|
26
|
+
let _state: State = 'unconfigured';
|
|
27
|
+
let _config: ResolvedConfig | null = null;
|
|
28
|
+
let _inflight: Promise<ResolvedConfig> | null = null;
|
|
29
|
+
let _inflightKey: string | null = null;
|
|
30
|
+
|
|
31
|
+
export function isConfigured(): boolean {
|
|
32
|
+
return _state === 'configured' && _config !== null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getResolvedConfig(): ResolvedConfig | null {
|
|
36
|
+
return _config;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Test-only reset of module state. Not exported from the package index.
|
|
40
|
+
export function __resetConfigForTest(): void {
|
|
41
|
+
_state = 'unconfigured';
|
|
42
|
+
_config = null;
|
|
43
|
+
_inflight = null;
|
|
44
|
+
_inflightKey = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeTimeout(ms: unknown): number {
|
|
48
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms)) return DEFAULT_TIMEOUT_MS;
|
|
49
|
+
return Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, Math.floor(ms)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Only echo a server-provided message after scrubbing it (defense in depth —
|
|
53
|
+
// the publishableKeyAuth messages are static today, but never trust that).
|
|
54
|
+
function safeMessage(msg: unknown, fallback: string): string {
|
|
55
|
+
return typeof msg === 'string' && msg.trim() ? redactSecrets(msg.trim()) : fallback;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Map a non-2xx GET /v1/sdk/config response to a typed error, keyed off the
|
|
59
|
+
// real backend shapes (publishableKeyAuth.js). Pure + unit-tested.
|
|
60
|
+
export function mapConfigError(
|
|
61
|
+
status: number,
|
|
62
|
+
body: { code?: string; message?: string; onboardingStatus?: string } | null | undefined,
|
|
63
|
+
retryAfterHeader?: string | null,
|
|
64
|
+
): GbitXPayError {
|
|
65
|
+
const b = body || {};
|
|
66
|
+
if (status === 403 && b.code === 'ONBOARDING_REQUIRED') {
|
|
67
|
+
return new GbitXPayError('onboarding_required', safeMessage(b.message, 'This publishable key is not active yet. Complete onboarding to activate.'), {
|
|
68
|
+
onboardingStatus: typeof b.onboardingStatus === 'string' ? b.onboardingStatus : undefined,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (status === 403) {
|
|
72
|
+
return new GbitXPayError('merchant_suspended', safeMessage(b.message, 'This merchant account is not active.'));
|
|
73
|
+
}
|
|
74
|
+
if (status === 429) {
|
|
75
|
+
const ra = Number(retryAfterHeader);
|
|
76
|
+
return new GbitXPayError('rate_limited', safeMessage(b.message, 'Too many requests. Please try again shortly.'), {
|
|
77
|
+
retryAfterSec: Number.isFinite(ra) && ra > 0 ? ra : undefined,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (status >= 500) {
|
|
81
|
+
return new GbitXPayError('server_error', safeMessage(b.message, 'GbitXPay is temporarily unavailable. Please try again.'));
|
|
82
|
+
}
|
|
83
|
+
// 401 and any other 4xx -> the key was rejected.
|
|
84
|
+
return new GbitXPayError('invalid_key', safeMessage(b.message, 'The publishable key was rejected.'));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Validate a 2xx body's shape so 'configured' means the REAL backend answered
|
|
88
|
+
// correctly (a captive portal / CDN error page / partial deploy that returns
|
|
89
|
+
// 200 with junk is treated as server_error, not silently accepted). Pure.
|
|
90
|
+
export function parseConfigSuccess(body: unknown): { environment: Environment; checkoutOrigin: string; merchant: MerchantInfo } {
|
|
91
|
+
const b = body as {
|
|
92
|
+
success?: unknown;
|
|
93
|
+
environment?: unknown;
|
|
94
|
+
checkoutBaseUrl?: unknown;
|
|
95
|
+
merchant?: { name?: unknown; website?: unknown } | null;
|
|
96
|
+
} | null;
|
|
97
|
+
|
|
98
|
+
if (!b || b.success !== true) {
|
|
99
|
+
throw new GbitXPayError('server_error', 'Unexpected response while validating the key.');
|
|
100
|
+
}
|
|
101
|
+
if (b.environment !== 'live' && b.environment !== 'test') {
|
|
102
|
+
throw new GbitXPayError('server_error', 'Unexpected config response (environment).');
|
|
103
|
+
}
|
|
104
|
+
let checkoutOrigin: string;
|
|
105
|
+
try {
|
|
106
|
+
checkoutOrigin = assertHttpsOrigin(b.checkoutBaseUrl, 'checkoutBaseUrl');
|
|
107
|
+
} catch {
|
|
108
|
+
throw new GbitXPayError('server_error', 'Insecure or invalid checkout origin from server.');
|
|
109
|
+
}
|
|
110
|
+
const m = b.merchant;
|
|
111
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) {
|
|
112
|
+
throw new GbitXPayError('server_error', 'Unexpected config response (merchant).');
|
|
113
|
+
}
|
|
114
|
+
const name = typeof m.name === 'string' && m.name.trim() ? m.name.trim() : 'Merchant';
|
|
115
|
+
const website = typeof m.website === 'string' && m.website.trim() ? m.website.trim() : null;
|
|
116
|
+
return { environment: b.environment, checkoutOrigin, merchant: { name, website } };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function fetchConfig(key: string, apiBase: string, timeoutMs: number): Promise<ResolvedConfig> {
|
|
120
|
+
const url = `${apiBase}/v1/sdk/config`;
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
123
|
+
|
|
124
|
+
let res: Response;
|
|
125
|
+
try {
|
|
126
|
+
res = await fetch(url, {
|
|
127
|
+
method: 'GET',
|
|
128
|
+
signal: controller.signal,
|
|
129
|
+
headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
|
|
130
|
+
});
|
|
131
|
+
} catch (cause) {
|
|
132
|
+
throw new GbitXPayError('network_error', 'Could not reach GbitXPay to validate the publishable key.', { cause });
|
|
133
|
+
} finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Body may be non-JSON (proxy/CDN error page) — parse defensively.
|
|
138
|
+
const body = await res.json().catch(() => null);
|
|
139
|
+
|
|
140
|
+
if (!res.ok) {
|
|
141
|
+
throw mapConfigError(res.status, body as never, res.headers?.get?.('retry-after'));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const parsed = parseConfigSuccess(body);
|
|
145
|
+
return {
|
|
146
|
+
publishableKey: key,
|
|
147
|
+
environment: parsed.environment,
|
|
148
|
+
checkoutOrigin: parsed.checkoutOrigin,
|
|
149
|
+
merchant: parsed.merchant,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Validate the publishable key against the GbitX backend and prepare the SDK.
|
|
154
|
+
// Must complete successfully before present() can run. Safe to call more than
|
|
155
|
+
// once (an in-flight call for the SAME key is shared; the last successful
|
|
156
|
+
// configure wins).
|
|
157
|
+
export async function configure(options: ConfigureOptions): Promise<void> {
|
|
158
|
+
if (options && options.debug) setDebug(true);
|
|
159
|
+
|
|
160
|
+
const { key, environment: prefixEnv } = assertPublishableKey(options?.publishableKey);
|
|
161
|
+
|
|
162
|
+
if (options.environment && options.environment !== prefixEnv) {
|
|
163
|
+
throw new GbitXPayError('environment_mismatch', 'The environment option does not match the publishable key.');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const timeoutMs = normalizeTimeout(options.timeoutMs);
|
|
167
|
+
const apiBase = options.apiBaseUrl ? assertHttpsOrigin(options.apiBaseUrl, 'apiBaseUrl') : DEFAULT_API_BASE;
|
|
168
|
+
|
|
169
|
+
// Share an in-flight configure for the same key.
|
|
170
|
+
if (_inflight && _inflightKey === key) {
|
|
171
|
+
await _inflight;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
_state = 'configuring';
|
|
176
|
+
debugLog('configure: validating key against', `${apiBase}/v1/sdk/config`);
|
|
177
|
+
|
|
178
|
+
const run = (async (): Promise<ResolvedConfig> => {
|
|
179
|
+
const resolved = await fetchConfig(key, apiBase, timeoutMs);
|
|
180
|
+
// Cross-check the key prefix against the server's environment. A mismatch
|
|
181
|
+
// is a fatal misconfiguration (mis-issued/tampered key or wrong backend).
|
|
182
|
+
if (resolved.environment !== prefixEnv) {
|
|
183
|
+
throw new GbitXPayError('environment_mismatch', 'Key environment and server environment disagree.');
|
|
184
|
+
}
|
|
185
|
+
if (options.environment && options.environment !== resolved.environment) {
|
|
186
|
+
throw new GbitXPayError('environment_mismatch', 'The environment option does not match the server environment.');
|
|
187
|
+
}
|
|
188
|
+
return resolved;
|
|
189
|
+
})();
|
|
190
|
+
|
|
191
|
+
_inflight = run;
|
|
192
|
+
_inflightKey = key;
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const resolved = await run;
|
|
196
|
+
// Only commit if THIS run is still the current in-flight one. A later
|
|
197
|
+
// configure() (different key) may have superseded us while we awaited; a
|
|
198
|
+
// stale, superseded run must never overwrite the winning checkoutOrigin
|
|
199
|
+
// (the root of trust). If superseded, drop our result silently.
|
|
200
|
+
if (_inflight === run) {
|
|
201
|
+
_config = resolved;
|
|
202
|
+
_state = 'configured';
|
|
203
|
+
debugLog('configure: ok, environment =', resolved.environment);
|
|
204
|
+
}
|
|
205
|
+
} catch (err) {
|
|
206
|
+
// Only flip to failed if this run is still the current one (a newer
|
|
207
|
+
// configure() may have superseded us).
|
|
208
|
+
if (_inflight === run) {
|
|
209
|
+
_state = 'configure_failed';
|
|
210
|
+
_config = null;
|
|
211
|
+
}
|
|
212
|
+
throw err;
|
|
213
|
+
} finally {
|
|
214
|
+
if (_inflight === run) {
|
|
215
|
+
_inflight = null;
|
|
216
|
+
_inflightKey = null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { GbitXPayError } from './errors';
|
|
2
|
+
import { assertPaymentArgs, buildCheckoutUrl } from './validation';
|
|
3
|
+
import { getResolvedConfig, isConfigured, type ResolvedConfig } from './config';
|
|
4
|
+
import type { PaymentResult, PresentOptions } from './types';
|
|
5
|
+
|
|
6
|
+
// One active checkout session, handed to the mounted host (GbitXPayProvider)
|
|
7
|
+
// to render. `settle` is the SINGLE exit: the controller dedupes it and
|
|
8
|
+
// resolves the present() promise, then tells the host to unmount.
|
|
9
|
+
export interface CheckoutSession {
|
|
10
|
+
paymentId: string;
|
|
11
|
+
clientToken: string;
|
|
12
|
+
url: string;
|
|
13
|
+
config: ResolvedConfig;
|
|
14
|
+
onLoaded?: () => void;
|
|
15
|
+
onEvent?: PresentOptions['onEvent'];
|
|
16
|
+
settle: (result: PaymentResult) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type HostListener = (session: CheckoutSession | null) => void;
|
|
20
|
+
|
|
21
|
+
let _host: HostListener | null = null;
|
|
22
|
+
let _active: CheckoutSession | null = null;
|
|
23
|
+
|
|
24
|
+
// The provider registers exactly one host. If a host is already registered
|
|
25
|
+
// (two providers mounted — a merchant mistake), the last one wins and we warn
|
|
26
|
+
// via the returned unregister being scoped to the specific listener.
|
|
27
|
+
export function registerHost(listener: HostListener): () => void {
|
|
28
|
+
_host = listener;
|
|
29
|
+
if (_active) listener(_active); // replay a pending session to a late host
|
|
30
|
+
return () => {
|
|
31
|
+
if (_host === listener) _host = null;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function hasHost(): boolean {
|
|
36
|
+
return _host !== null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Test-only.
|
|
40
|
+
export function __resetControllerForTest(): void {
|
|
41
|
+
_host = null;
|
|
42
|
+
_active = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Open the hosted checkout for one payment. Resolves with the terminal
|
|
46
|
+
// PaymentResult (confirmed / cancelled / expired / failed). Rejects ONLY for
|
|
47
|
+
// SDK/credential faults (GbitXPayError). A terminal outcome is never a reject.
|
|
48
|
+
export function present(options: PresentOptions): Promise<PaymentResult> {
|
|
49
|
+
return new Promise<PaymentResult>((resolve, reject) => {
|
|
50
|
+
if (!isConfigured()) {
|
|
51
|
+
reject(new GbitXPayError('not_configured', 'Call and await GbitXPay.configure({ publishableKey }) before present().'));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!_host) {
|
|
55
|
+
reject(new GbitXPayError('not_mounted', 'Mount <GbitXPayProvider> once near your app root before calling present().'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (_active) {
|
|
59
|
+
reject(new GbitXPayError('invalid_arguments', 'A checkout is already open.'));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let args: { paymentId: string; clientToken: string };
|
|
64
|
+
try {
|
|
65
|
+
args = assertPaymentArgs(options?.paymentId, options?.clientToken);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
reject(e);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const config = getResolvedConfig();
|
|
72
|
+
if (!config) {
|
|
73
|
+
// Guarded by isConfigured() above; defensive.
|
|
74
|
+
reject(new GbitXPayError('not_configured', 'Configuration was lost. Call configure() again.'));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const url = buildCheckoutUrl(config.checkoutOrigin, args.paymentId, args.clientToken);
|
|
79
|
+
|
|
80
|
+
let done = false;
|
|
81
|
+
const session: CheckoutSession = {
|
|
82
|
+
paymentId: args.paymentId,
|
|
83
|
+
clientToken: args.clientToken,
|
|
84
|
+
url,
|
|
85
|
+
config,
|
|
86
|
+
onLoaded: options.onLoaded,
|
|
87
|
+
onEvent: options.onEvent,
|
|
88
|
+
settle: (result: PaymentResult) => {
|
|
89
|
+
if (done) return; // controller-level latch (the modal has its own too)
|
|
90
|
+
done = true;
|
|
91
|
+
_active = null;
|
|
92
|
+
_host?.(null); // ask the host to unmount the modal/WebView
|
|
93
|
+
resolve(result);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
_active = session;
|
|
98
|
+
_host(session);
|
|
99
|
+
});
|
|
100
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The ONE public error type. Terminal payment outcomes (cancelled / expired
|
|
2
|
+
// / failed) are NOT errors — they resolve as a PaymentResult. Throw/reject
|
|
3
|
+
// only for SDK or credential faults. `code` is a stable, closed union;
|
|
4
|
+
// changing it is a semver-major event.
|
|
5
|
+
|
|
6
|
+
export type GbitXPayErrorCode =
|
|
7
|
+
// present() called before configure() completed successfully.
|
|
8
|
+
| 'not_configured'
|
|
9
|
+
// present() called with no <GbitXPayProvider> mounted in the tree.
|
|
10
|
+
| 'not_mounted'
|
|
11
|
+
// publishable key wrong/revoked/malformed, or a config 4xx.
|
|
12
|
+
| 'invalid_key'
|
|
13
|
+
// a SECRET key (gk_) was handed to the SDK — a serious integration bug.
|
|
14
|
+
| 'secret_key_used'
|
|
15
|
+
// key environment and server environment disagree.
|
|
16
|
+
| 'environment_mismatch'
|
|
17
|
+
// merchant not yet approved for this (live) key.
|
|
18
|
+
| 'onboarding_required'
|
|
19
|
+
// merchant account suspended.
|
|
20
|
+
| 'merchant_suspended'
|
|
21
|
+
// config endpoint rate-limited (429).
|
|
22
|
+
| 'rate_limited'
|
|
23
|
+
// could not reach GbitX (DNS/TLS/offline/timeout).
|
|
24
|
+
| 'network_error'
|
|
25
|
+
// GbitX reachable but returned 5xx or an unusable body.
|
|
26
|
+
| 'server_error'
|
|
27
|
+
// bad arguments to configure()/present() (e.g. malformed paymentId).
|
|
28
|
+
| 'invalid_arguments';
|
|
29
|
+
|
|
30
|
+
// Publicly readable meta — ONLY safe, serializable fields. The transport
|
|
31
|
+
// `cause` is accepted by the constructor but is never placed on an enumerable
|
|
32
|
+
// property (see below), so it can't be captured by `{...error}`, JSON, or a
|
|
33
|
+
// crash reporter that walks own-enumerable keys.
|
|
34
|
+
export interface GbitXPayErrorMeta {
|
|
35
|
+
// Present on 'onboarding_required'.
|
|
36
|
+
onboardingStatus?: string;
|
|
37
|
+
// Present on 'rate_limited' when the server sent Retry-After.
|
|
38
|
+
retryAfterSec?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// What the constructor accepts. `cause` may reference a Request/URL holding a
|
|
42
|
+
// token, so it is stored non-enumerably on the standard Error `cause` slot and
|
|
43
|
+
// kept out of `meta`, `message`, and `toJSON()`.
|
|
44
|
+
export type GbitXPayErrorInit = GbitXPayErrorMeta & { cause?: unknown };
|
|
45
|
+
|
|
46
|
+
export class GbitXPayError extends Error {
|
|
47
|
+
readonly code: GbitXPayErrorCode;
|
|
48
|
+
readonly meta?: GbitXPayErrorMeta;
|
|
49
|
+
|
|
50
|
+
constructor(code: GbitXPayErrorCode, message: string, init?: GbitXPayErrorInit) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = 'GbitXPayError';
|
|
53
|
+
this.code = code;
|
|
54
|
+
|
|
55
|
+
// Only safe fields go on the enumerable, serializable `meta`.
|
|
56
|
+
const meta: GbitXPayErrorMeta = {};
|
|
57
|
+
if (init?.onboardingStatus) meta.onboardingStatus = init.onboardingStatus;
|
|
58
|
+
if (typeof init?.retryAfterSec === 'number') meta.retryAfterSec = init.retryAfterSec;
|
|
59
|
+
this.meta = Object.keys(meta).length ? meta : undefined;
|
|
60
|
+
|
|
61
|
+
// The cause (if any) is stored NON-enumerably so spreads / JSON / crash
|
|
62
|
+
// reporters that walk own-enumerable keys never see it.
|
|
63
|
+
if (init && 'cause' in init) {
|
|
64
|
+
Object.defineProperty(this, 'cause', { value: init.cause, enumerable: false, writable: false, configurable: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Keep instanceof working across transpile targets / bundlers.
|
|
68
|
+
Object.setPrototypeOf(this, GbitXPayError.prototype);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Custom serialization so a crash reporter or JSON.stringify() can never
|
|
72
|
+
// leak `cause` (which might hold the checkout URL with #t=<clientToken>).
|
|
73
|
+
// Only the safe, non-sensitive fields are emitted.
|
|
74
|
+
toJSON() {
|
|
75
|
+
const out: Record<string, unknown> = {
|
|
76
|
+
name: this.name,
|
|
77
|
+
code: this.code,
|
|
78
|
+
message: this.message,
|
|
79
|
+
};
|
|
80
|
+
if (this.meta?.onboardingStatus) out.onboardingStatus = this.meta.onboardingStatus;
|
|
81
|
+
if (typeof this.meta?.retryAfterSec === 'number') out.retryAfterSec = this.meta.retryAfterSec;
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Narrowing helper for consumers who don't want `instanceof`.
|
|
87
|
+
export function isGbitXPayError(e: unknown): e is GbitXPayError {
|
|
88
|
+
return e instanceof GbitXPayError || (typeof e === 'object' && e !== null && (e as { name?: string }).name === 'GbitXPayError');
|
|
89
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @gbitx/pay-react-native — public entrypoint.
|
|
2
|
+
//
|
|
3
|
+
// Accept crypto payments in your React Native app. Your SERVER creates the
|
|
4
|
+
// payment with your SECRET key and hands the app { paymentId, clientToken };
|
|
5
|
+
// the app opens the hardened hosted checkout in a WebView. Fulfilment is
|
|
6
|
+
// driven by the signed server webhook — SDK callbacks are UX only.
|
|
7
|
+
|
|
8
|
+
export { GbitXPay } from './GbitXPay';
|
|
9
|
+
export type { GbitXPayApi } from './GbitXPay';
|
|
10
|
+
export { GbitXPayProvider } from './GbitXPayProvider';
|
|
11
|
+
export { useGbitXPay } from './useGbitXPay';
|
|
12
|
+
export type { UseGbitXPay } from './useGbitXPay';
|
|
13
|
+
|
|
14
|
+
export { GbitXPayError, isGbitXPayError } from './errors';
|
|
15
|
+
export type { GbitXPayErrorCode, GbitXPayErrorMeta } from './errors';
|
|
16
|
+
|
|
17
|
+
export { setDebug } from './redact';
|
|
18
|
+
|
|
19
|
+
export { SDK_VERSION } from './version';
|
|
20
|
+
|
|
21
|
+
export type {
|
|
22
|
+
Environment,
|
|
23
|
+
PaymentStatus,
|
|
24
|
+
SanitizedPayment,
|
|
25
|
+
PaymentResult,
|
|
26
|
+
CancelReason,
|
|
27
|
+
ExpiredReason,
|
|
28
|
+
ConfigureOptions,
|
|
29
|
+
PresentOptions,
|
|
30
|
+
MerchantInfo,
|
|
31
|
+
CheckoutEvent,
|
|
32
|
+
CheckoutEventType,
|
|
33
|
+
} from './types';
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { CheckoutEventType, PaymentResult, PaymentStatus, SanitizedPayment } from './types';
|
|
2
|
+
|
|
3
|
+
// Exact set of bridge event types the checkout page emits. Anything else is
|
|
4
|
+
// dropped. Keys are the wire `type` strings; values are the short event names.
|
|
5
|
+
const KNOWN_TYPES: Record<string, CheckoutEventType> = {
|
|
6
|
+
'gbitx:payment:loaded': 'loaded',
|
|
7
|
+
'gbitx:payment:confirmed': 'confirmed',
|
|
8
|
+
'gbitx:payment:cancelled': 'cancelled',
|
|
9
|
+
'gbitx:payment:expired': 'expired',
|
|
10
|
+
'gbitx:payment:failed': 'failed',
|
|
11
|
+
'gbitx:payment:closed': 'closed',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// A bridge message is a single JSON string. Cap it well under any sane size so
|
|
15
|
+
// a hostile page can't feed us a giant string before we even parse.
|
|
16
|
+
const MAX_MESSAGE_BYTES = 8192;
|
|
17
|
+
|
|
18
|
+
const PAYMENT_STATUSES: readonly PaymentStatus[] = [
|
|
19
|
+
'PENDING', 'AWAITING_PAYMENT', 'UNDERPAID', 'CONFIRMING', 'CONFIRMED', 'EXPIRED', 'FAILED', 'CANCELLED',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export interface ParsedMessage {
|
|
23
|
+
type: CheckoutEventType;
|
|
24
|
+
// null for :loaded (the page sends {}) or when a payment payload is absent
|
|
25
|
+
// / malformed. Terminal handling tolerates null (webhook is truth).
|
|
26
|
+
payment: SanitizedPayment | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function asString(v: unknown): string | null {
|
|
30
|
+
return typeof v === 'string' ? v : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Accept string OR finite number for decimal amount fields (the page sends
|
|
34
|
+
// strings — Prisma Decimal — but we stay tolerant) and normalize to a plain
|
|
35
|
+
// decimal string. A number whose String() form uses exponential notation
|
|
36
|
+
// (very large, or small like 1e-8) is dropped to null rather than surfaced as
|
|
37
|
+
// "1e-8"/"1e+21"; the field is display-only, so null is safer than a
|
|
38
|
+
// misleading exponent.
|
|
39
|
+
function asDecimalString(v: unknown): string | null {
|
|
40
|
+
if (typeof v === 'string') return v;
|
|
41
|
+
if (typeof v === 'number' && Number.isFinite(v)) {
|
|
42
|
+
const s = String(v);
|
|
43
|
+
return s.includes('e') || s.includes('E') ? null : s;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Re-pick ONLY the whitelisted fields into a fresh object. This discards any
|
|
49
|
+
// extra keys (prototype-pollution vectors, functions, nested junk, and the
|
|
50
|
+
// fields the page deliberately strips). Returns null if the payload is not a
|
|
51
|
+
// usable payment (no id or unknown status).
|
|
52
|
+
export function sanitizePayment(input: unknown): SanitizedPayment | null {
|
|
53
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
|
|
54
|
+
const p = input as Record<string, unknown>;
|
|
55
|
+
|
|
56
|
+
const id = asString(p.id);
|
|
57
|
+
const status = asString(p.status);
|
|
58
|
+
if (!id || !status || !(PAYMENT_STATUSES as readonly string[]).includes(status)) return null;
|
|
59
|
+
|
|
60
|
+
const mode = p.mode === 'LIVE' || p.mode === 'SANDBOX' ? p.mode : 'SANDBOX';
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
id,
|
|
64
|
+
status: status as PaymentStatus,
|
|
65
|
+
amount: asDecimalString(p.amount) ?? '',
|
|
66
|
+
currency: asString(p.currency) ?? '',
|
|
67
|
+
crypto: asString(p.crypto),
|
|
68
|
+
network: asString(p.network),
|
|
69
|
+
amountCrypto: asDecimalString(p.amountCrypto),
|
|
70
|
+
amountPaid: asDecimalString(p.amountPaid),
|
|
71
|
+
mode,
|
|
72
|
+
confirmedAt: asString(p.confirmedAt),
|
|
73
|
+
expiresAt: asString(p.expiresAt) ?? '',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Defensively parse a raw onMessage string into a known event, or null. Never
|
|
78
|
+
// throws. Callers MUST have already verified the message origin (the WebView's
|
|
79
|
+
// committed top-frame URL) before invoking this.
|
|
80
|
+
export function parseBridgeMessage(raw: unknown): ParsedMessage | null {
|
|
81
|
+
if (typeof raw !== 'string' || raw.length === 0 || raw.length > MAX_MESSAGE_BYTES) return null;
|
|
82
|
+
|
|
83
|
+
let obj: unknown;
|
|
84
|
+
try {
|
|
85
|
+
obj = JSON.parse(raw);
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null;
|
|
90
|
+
|
|
91
|
+
const type = (obj as { type?: unknown }).type;
|
|
92
|
+
if (typeof type !== 'string') return null;
|
|
93
|
+
const mapped = KNOWN_TYPES[type];
|
|
94
|
+
if (!mapped) return null;
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
type: mapped,
|
|
98
|
+
payment: sanitizePayment((obj as { payment?: unknown }).payment),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// True for the events that end a checkout session.
|
|
103
|
+
export function isTerminal(type: CheckoutEventType): boolean {
|
|
104
|
+
return type !== 'loaded';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Map a terminal event to a PaymentResult. `loaded` is non-terminal -> null.
|
|
108
|
+
// `closed` and `cancelled` collapse to a single cancelled representation.
|
|
109
|
+
export function terminalResult(type: CheckoutEventType, payment: SanitizedPayment | null): PaymentResult | null {
|
|
110
|
+
switch (type) {
|
|
111
|
+
case 'confirmed':
|
|
112
|
+
return { status: 'confirmed', payment };
|
|
113
|
+
case 'cancelled':
|
|
114
|
+
case 'closed':
|
|
115
|
+
return { status: 'cancelled', reason: 'user_closed', payment };
|
|
116
|
+
case 'expired':
|
|
117
|
+
return { status: 'expired', reason: 'expired', payment };
|
|
118
|
+
case 'failed':
|
|
119
|
+
return { status: 'failed', payment };
|
|
120
|
+
case 'loaded':
|
|
121
|
+
default:
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/redact.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Credential hygiene. The publishable key, the client token, and the full
|
|
2
|
+
// checkout URL (which carries #t=<clientToken>) must NEVER hit a log, a
|
|
3
|
+
// breadcrumb, or a serialized error. Everything here is defensive: even the
|
|
4
|
+
// debug logger routes through redaction.
|
|
5
|
+
|
|
6
|
+
// Reduce a URL to origin + pathname, dropping the query and fragment (where
|
|
7
|
+
// the token lives). Use this anywhere a URL might be surfaced.
|
|
8
|
+
export function redactUrl(url: string): string {
|
|
9
|
+
try {
|
|
10
|
+
const u = new URL(url);
|
|
11
|
+
return `${u.origin}${u.pathname}`;
|
|
12
|
+
} catch {
|
|
13
|
+
return '[unparseable-url]';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Scrub key-shaped and token substrings from arbitrary text.
|
|
18
|
+
export function redactSecrets(input: string): string {
|
|
19
|
+
return input
|
|
20
|
+
// gk_/pk_ live/test keys -> keep the class prefix, redact the body.
|
|
21
|
+
.replace(/([gp]k_(?:live|test)_)[0-9a-f]{6,}/gi, '$1redacted')
|
|
22
|
+
// t= token in a fragment OR query string (#t=…, ?t=…, &t=…).
|
|
23
|
+
.replace(/([#?&]t=)[^\s&"']+/g, '$1redacted')
|
|
24
|
+
// Authorization: Bearer <key/token>.
|
|
25
|
+
.replace(/(Bearer\s+)[^\s"']+/gi, '$1redacted');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function safeStringify(value: unknown): string {
|
|
29
|
+
const seen = new WeakSet<object>();
|
|
30
|
+
try {
|
|
31
|
+
return JSON.stringify(value, (_k, v) => {
|
|
32
|
+
if (typeof v === 'object' && v !== null) {
|
|
33
|
+
if (seen.has(v)) return '[circular]';
|
|
34
|
+
seen.add(v);
|
|
35
|
+
}
|
|
36
|
+
return v;
|
|
37
|
+
});
|
|
38
|
+
} catch {
|
|
39
|
+
return String(value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let _debug = false;
|
|
44
|
+
export function setDebug(on: boolean): void {
|
|
45
|
+
_debug = !!on;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Debug logger. Off by default; even when on it redacts every line.
|
|
49
|
+
export function debugLog(...parts: unknown[]): void {
|
|
50
|
+
if (!_debug) return;
|
|
51
|
+
try {
|
|
52
|
+
const line = parts.map((p) => (typeof p === 'string' ? p : safeStringify(p))).join(' ');
|
|
53
|
+
// eslint-disable-next-line no-console
|
|
54
|
+
console.log('[GbitXPay]', redactSecrets(line));
|
|
55
|
+
} catch {
|
|
56
|
+
/* logging must never throw */
|
|
57
|
+
}
|
|
58
|
+
}
|