@staxpayments/staxpayments-js 2.30.18
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/.babelrc +8 -0
- package/LICENSE +21 -0
- package/README.md +1192 -0
- package/dist/blockchyp-js-all.js +48535 -0
- package/dist/blockchyp-js-all.min.js +34 -0
- package/dist/client.js +972 -0
- package/dist/cryptoutils.js +111 -0
- package/dist/global.js +13 -0
- package/dist/mappers.js +75 -0
- package/dist/payments.js +94 -0
- package/dist/staxpaymentsclient.js +74 -0
- package/dist/terminals.js +308 -0
- package/eslint.config.mjs +34 -0
- package/index.js +4 -0
- package/package.json +69 -0
- package/spec/CryptoSpec.js +46 -0
- package/spec/SanitySpec.js +27 -0
- package/spec/support/jasmine.json +11 -0
- package/src/client.js +707 -0
- package/src/cryptoutils.js +95 -0
- package/src/global.js +6 -0
- package/src/mappers.js +72 -0
- package/src/payments.js +41 -0
- package/src/staxpaymentsclient.js +54 -0
- package/src/terminals.js +104 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2019-2026 BlockChyp, Inc. All rights reserved. Use of this code is governed
|
|
3
|
+
* by a license that can be found in the LICENSE file.
|
|
4
|
+
*
|
|
5
|
+
* This file was generated automatically by the BlockChyp SDK Generator. Changes to this
|
|
6
|
+
* file will be lost every time the code is regenerated.
|
|
7
|
+
*/
|
|
8
|
+
import axios from 'axios'
|
|
9
|
+
import CryptoUtils from './cryptoutils'
|
|
10
|
+
import nodeHttps from 'https'
|
|
11
|
+
import browserifyHttps from 'https-browserify'
|
|
12
|
+
import aesjs from 'aes-js'
|
|
13
|
+
import { sha256 } from '@noble/hashes/sha256'
|
|
14
|
+
import { randomBytes, bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'
|
|
15
|
+
|
|
16
|
+
// optionalNodeModule resolves a Node built-in at run time, returning undefined
|
|
17
|
+
// in the browser. The name is held in a variable so the browser bundler cannot
|
|
18
|
+
// statically resolve it and try to shim it into the bundle.
|
|
19
|
+
function optionalNodeModule (name) {
|
|
20
|
+
try {
|
|
21
|
+
if (typeof module !== 'undefined' && typeof module.require === 'function') {
|
|
22
|
+
return module.require(name)
|
|
23
|
+
}
|
|
24
|
+
} catch (e) {
|
|
25
|
+
// Not running under Node.
|
|
26
|
+
}
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* eslint-disable no-unused-vars */
|
|
31
|
+
export const CardType = Object.freeze({
|
|
32
|
+
CREDIT: 0,
|
|
33
|
+
DEBIT: 1,
|
|
34
|
+
EBT: 2,
|
|
35
|
+
BLOCKCHAIN_GIFT: 3,
|
|
36
|
+
HEALTHCARE: 4,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export const SignatureFormat = Object.freeze({
|
|
40
|
+
NONE: '',
|
|
41
|
+
PNG: 'png',
|
|
42
|
+
JPG: 'jpg',
|
|
43
|
+
GIF: 'gif',
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
export const RoundingMode = Object.freeze({
|
|
47
|
+
UP: 'up',
|
|
48
|
+
NEAREST: 'nearest',
|
|
49
|
+
DOWN: 'down',
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
export const PromptType = Object.freeze({
|
|
53
|
+
AMOUNT: 'amount',
|
|
54
|
+
EMAIL: 'email',
|
|
55
|
+
PHONE_NUMBER: 'phone',
|
|
56
|
+
CUSTOMER_NUMBER: 'customer-number',
|
|
57
|
+
REWARDS_NUMBER: 'rewards-number',
|
|
58
|
+
FIRST_NAME: 'first-name',
|
|
59
|
+
LAST_NAME: 'last-name',
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
export const AVSResponse = Object.freeze({
|
|
63
|
+
NOT_APPLICABLE: '',
|
|
64
|
+
NOT_SUPPORTED: 'not_supported',
|
|
65
|
+
RETRY: 'retry',
|
|
66
|
+
NO_MATCH: 'no_match',
|
|
67
|
+
ADDRESS_MATCH: 'address_match',
|
|
68
|
+
POSTAL_CODE_MATCH: 'zip_match',
|
|
69
|
+
ADDRESS_AND_POSTAL_CODE_MATCH: 'match',
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
export const CVMType = Object.freeze({
|
|
73
|
+
SIGNATURE: 'Signature',
|
|
74
|
+
OFFLINE_PIN: 'Offline PIN',
|
|
75
|
+
ONLINE_PIN: 'Online PIN',
|
|
76
|
+
CDCVM: 'CDCVM',
|
|
77
|
+
NO_CVM: 'No CVM',
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
export const HealthcareType = Object.freeze({
|
|
81
|
+
HEALTHCARE: 'healthcare',
|
|
82
|
+
PRESCRIPTION: 'prescription',
|
|
83
|
+
VISION: 'vision',
|
|
84
|
+
CLINIC: 'clinic',
|
|
85
|
+
DENTAL: 'dental',
|
|
86
|
+
})
|
|
87
|
+
/* eslint-enable no-unused-vars */
|
|
88
|
+
|
|
89
|
+
const VERSION = require('../package.json').version
|
|
90
|
+
const USER_AGENT = `StaxPayments-JavaScript/${VERSION}`
|
|
91
|
+
// Some browsers do not allow setting the user-agent header, so we set
|
|
92
|
+
// an alternative if running from a browser.
|
|
93
|
+
const AGENT_HEADER = (typeof window === 'undefined') ? 'User-Agent' : 'X-Requested-With'
|
|
94
|
+
|
|
95
|
+
// DEFAULT_CORE_HOST is the default Stax core API host used when none is supplied.
|
|
96
|
+
const DEFAULT_CORE_HOST = 'https://apiprod.fattlabs.com'
|
|
97
|
+
|
|
98
|
+
// TRANSIENT_CREDENTIALS_PATH is the core API path that exchanges a Stax bearer
|
|
99
|
+
// token for short-lived Stax Payments transient credentials. This is an
|
|
100
|
+
// internal mechanism of the SDK and is never exposed as a public method.
|
|
101
|
+
const TRANSIENT_CREDENTIALS_PATH = '/terminals/transient-credentials'
|
|
102
|
+
|
|
103
|
+
// EXPIRY_SKEW_MS refreshes transient credentials this many milliseconds before
|
|
104
|
+
// their stated expiry to avoid a credential lapsing mid-request (clock-skew
|
|
105
|
+
// buffer).
|
|
106
|
+
const EXPIRY_SKEW_MS = 30 * 1000
|
|
107
|
+
|
|
108
|
+
// EXPIRY_FALLBACK_MS is how long transient credentials are assumed to live when
|
|
109
|
+
// the core API does not state an expiry. The core endpoint does not populate
|
|
110
|
+
// expiresAt yet, so in practice this is the credential lifetime.
|
|
111
|
+
const EXPIRY_FALLBACK_MS = 8 * 60 * 60 * 1000
|
|
112
|
+
|
|
113
|
+
// OFFLINE_FIXED_KEY is the static half of the offline route cache key. It is
|
|
114
|
+
// hashed together with the current signing key, so a cache file is readable
|
|
115
|
+
// only by a client holding the same credentials. It matches the constant used
|
|
116
|
+
// by the other Stax Payments SDKs, so the cache format is portable between
|
|
117
|
+
// them.
|
|
118
|
+
const OFFLINE_FIXED_KEY = 'cb22789c9d5c344a10e0474f134db39e25eb3bbf5a1b1a5e89b507f15ea9519c'
|
|
119
|
+
|
|
120
|
+
// StaxPaymentsBaseClient holds the shared transport: host configuration,
|
|
121
|
+
// transient-credential exchange, and the gateway/dashboard/terminal/core
|
|
122
|
+
// request plumbing. Each API namespace ships a client that wraps this base and
|
|
123
|
+
// adds only the endpoint methods for that namespace (e.g. PaymentsClient); the
|
|
124
|
+
// root StaxPaymentsClient builds one base and shares it across namespaces.
|
|
125
|
+
export class StaxPaymentsBaseClient {
|
|
126
|
+
// Construct the shared transport with your Stax bearer token. Terminal
|
|
127
|
+
// transactions transparently exchange it for short-lived transient
|
|
128
|
+
// credentials.
|
|
129
|
+
constructor (creds, opts = {}) {
|
|
130
|
+
this.gatewayHost = opts.gatewayHost || 'https://api.blockchyp.com'
|
|
131
|
+
this.testGatewayHost = 'https://test.blockchyp.com'
|
|
132
|
+
this.dashboardHost = 'https://dashboard.blockchyp.com'
|
|
133
|
+
this.coreHost = opts.coreHost || DEFAULT_CORE_HOST
|
|
134
|
+
this.bearerToken = creds.bearerToken
|
|
135
|
+
// bcCredentials holds the short-lived BlockChyp transient credentials
|
|
136
|
+
// obtained by exchanging the Stax bearer token. SDK-managed; never set by
|
|
137
|
+
// the integrator.
|
|
138
|
+
this.bcCredentials = undefined
|
|
139
|
+
this.bcCredentialsExpiresAtMs = 0
|
|
140
|
+
// Single-flight guard: concurrent callers share one in-flight exchange.
|
|
141
|
+
this.bcCredentialsInFlight = undefined
|
|
142
|
+
this.https = true
|
|
143
|
+
this.cloudRelay = false
|
|
144
|
+
this.routeCacheTTL = 60
|
|
145
|
+
this.gatewayTimeout = 20
|
|
146
|
+
this.terminalTimeout = 120
|
|
147
|
+
this._routeCache = {}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
getGatewayHost () {
|
|
151
|
+
return this.gatewayHost
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
getDashboardHost () {
|
|
155
|
+
return this.dashboardHost
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
setGatewayHost (host) {
|
|
159
|
+
this.gatewayHost = host
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
setDashboardHost (host) {
|
|
163
|
+
this.dashboardHost = host
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
setTestGatewayHost (host) {
|
|
167
|
+
this.testGatewayHost = host
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// setCoreHost overrides the Stax core API host. An empty host resets it to the
|
|
171
|
+
// default. Cached credentials remain valid; subsequent exchanges and
|
|
172
|
+
// core-routed calls use the new host.
|
|
173
|
+
setCoreHost (host) {
|
|
174
|
+
this.coreHost = host || DEFAULT_CORE_HOST
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
heartbeat () {
|
|
178
|
+
return this._gatewayRequest('get', '/api/heartbeat')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// routeTransaction runs a transaction end to end, dispatching based on whether
|
|
182
|
+
// a terminal is named: to the terminal (card-present), directly or via cloud
|
|
183
|
+
// relay, when a terminal name is supplied, or to the gateway (card-not-present)
|
|
184
|
+
// otherwise.
|
|
185
|
+
async routeTransaction (method, request, terminalPath, cloudPath) {
|
|
186
|
+
await this.ensure()
|
|
187
|
+
await this._populateSignatureOptions(request)
|
|
188
|
+
|
|
189
|
+
let response
|
|
190
|
+
if (this.isTerminalRouted(request)) {
|
|
191
|
+
// A terminal that cannot be routed is an error, not a reason to send the
|
|
192
|
+
// transaction somewhere else: _resolveTerminalRoute throws.
|
|
193
|
+
let route = await this._resolveTerminalRoute(request.terminalName)
|
|
194
|
+
response = route.cloudRelayEnabled
|
|
195
|
+
? await this._relayRequest(method, cloudPath, request)
|
|
196
|
+
: await this._terminalRequest(method, route, terminalPath, request)
|
|
197
|
+
} else {
|
|
198
|
+
response = await this._gatewayRequest(method, cloudPath, request)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// routeTransaction resolves to the axios response; the signature lives on
|
|
202
|
+
// the payload.
|
|
203
|
+
await this._handleSignature(request, response ? response.data : undefined)
|
|
204
|
+
|
|
205
|
+
return response
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async routeTransactionPost (request, terminalPath, cloudPath) {
|
|
209
|
+
return this.routeTransaction('post', request, terminalPath, cloudPath)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
returnValidationError (desc) {
|
|
213
|
+
let result = {
|
|
214
|
+
data: {
|
|
215
|
+
approved: false,
|
|
216
|
+
success: false,
|
|
217
|
+
error: desc
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return result
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
validateRequest (request) {
|
|
224
|
+
if (!this.validateCurrency(request.amount)) {
|
|
225
|
+
return false
|
|
226
|
+
}
|
|
227
|
+
return true
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
validateCurrency (val) {
|
|
231
|
+
let amt = parseFloat(val)
|
|
232
|
+
console.log(amt)
|
|
233
|
+
if (amt && !isNaN(amt)) {
|
|
234
|
+
let decMatch = val.match(/\./g || [])
|
|
235
|
+
if (decMatch && decMatch.length > 1) {
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
return false
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// isTerminalRouted reports whether a request names a terminal. Whether that
|
|
244
|
+
// terminal is reached directly or over cloud relay is a property of its
|
|
245
|
+
// route, resolved per terminal, not a client-wide setting.
|
|
246
|
+
isTerminalRouted (request) {
|
|
247
|
+
return Boolean(request && request.terminalName)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ensure guarantees the client holds valid merchant-scoped transient
|
|
251
|
+
// credentials, exchanging the Stax bearer token via the core API when the
|
|
252
|
+
// cache is empty or near expiry. Concurrent callers share one in-flight
|
|
253
|
+
// exchange (single-flight). Terminal calls invoke this before routing.
|
|
254
|
+
async ensure () {
|
|
255
|
+
if (this.bcCredentials && Date.now() + EXPIRY_SKEW_MS < this.bcCredentialsExpiresAtMs) {
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
if (this.bcCredentialsInFlight) {
|
|
259
|
+
await this.bcCredentialsInFlight
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
this.bcCredentialsInFlight = this._exchange()
|
|
263
|
+
try {
|
|
264
|
+
await this.bcCredentialsInFlight
|
|
265
|
+
} finally {
|
|
266
|
+
this.bcCredentialsInFlight = undefined
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async _exchange () {
|
|
271
|
+
// The cached credentials are left in place until the exchange succeeds. A
|
|
272
|
+
// failed refresh should not discard credentials that may still be usable,
|
|
273
|
+
// and it must not leave the client unauthenticated.
|
|
274
|
+
let response = await this._coreRequest('get', TRANSIENT_CREDENTIALS_PATH)
|
|
275
|
+
let data = response.data
|
|
276
|
+
this.bcCredentials = new StaxPaymentsCredentials(data.apiKey, data.bearerToken, data.signingKey)
|
|
277
|
+
this.bcCredentialsExpiresAtMs = data.expiresAt ? Date.parse(data.expiresAt) : Date.now() + EXPIRY_FALLBACK_MS
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
_relayRequest (method, path, request) {
|
|
281
|
+
return this._gatewayRequest(method, path, request, true)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async _uploadRequest (path, request, content) {
|
|
285
|
+
await this.ensure()
|
|
286
|
+
let config = {
|
|
287
|
+
method: 'post',
|
|
288
|
+
url: this._assembleDashboardUrl(path),
|
|
289
|
+
timeout: this._getTimeout(request, this.gatewayTimeout) * 1000,
|
|
290
|
+
headers: {
|
|
291
|
+
[AGENT_HEADER]: USER_AGENT,
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
config.data = content
|
|
296
|
+
|
|
297
|
+
if (this.bcCredentials && this.bcCredentials.apiKey) {
|
|
298
|
+
config.headers = Object.assign(config.headers, CryptoUtils.generateGatewayHeaders(this.bcCredentials))
|
|
299
|
+
}
|
|
300
|
+
if (request.fileSize) {
|
|
301
|
+
config.headers['X-File-Size'] = request.fileSize.toFixed()
|
|
302
|
+
}
|
|
303
|
+
if (request.fileName) {
|
|
304
|
+
config.headers['X-Upload-File-Name'] = request.fileName
|
|
305
|
+
}
|
|
306
|
+
if (request.uploadId) {
|
|
307
|
+
config.headers['X-Upload-ID'] = request.uploadId
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return axios(config)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async _dashboardRequest (method, path, request) {
|
|
314
|
+
await this.ensure()
|
|
315
|
+
let config = {
|
|
316
|
+
method: method,
|
|
317
|
+
url: this._assembleDashboardUrl(path),
|
|
318
|
+
timeout: this._getTimeout(request, this.gatewayTimeout) * 1000,
|
|
319
|
+
headers: {
|
|
320
|
+
[AGENT_HEADER]: USER_AGENT,
|
|
321
|
+
'Content-Type': 'application/json',
|
|
322
|
+
},
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (method !== 'get') {
|
|
326
|
+
config.data = request
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (this.bcCredentials && this.bcCredentials.apiKey) {
|
|
330
|
+
config.headers = Object.assign(config.headers, CryptoUtils.generateGatewayHeaders(this.bcCredentials))
|
|
331
|
+
}
|
|
332
|
+
return axios(config)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async _gatewayRequest (method, path, request, relay) {
|
|
336
|
+
await this.ensure()
|
|
337
|
+
let config = {
|
|
338
|
+
method: method,
|
|
339
|
+
url: this._assembleGatewayUrl(path, request),
|
|
340
|
+
timeout: this._getTimeout(request, relay ? this.terminalTimeout : this.gatewayTimeout) * 1000,
|
|
341
|
+
headers: {
|
|
342
|
+
[AGENT_HEADER]: USER_AGENT,
|
|
343
|
+
'Content-Type': 'application/json',
|
|
344
|
+
},
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (method !== 'get') {
|
|
348
|
+
config.data = request
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (this.bcCredentials && this.bcCredentials.apiKey) {
|
|
352
|
+
config.headers = Object.assign(config.headers, CryptoUtils.generateGatewayHeaders(this.bcCredentials))
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return axios(config)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// _coreRequest sends a request to the Stax core API authenticated with the
|
|
359
|
+
// Stax bearer token. It mirrors _gatewayRequest/_dashboardRequest, differing
|
|
360
|
+
// only in host (coreHost) and auth (the Stax bearer token rather than HMAC
|
|
361
|
+
// credentials). Used by core-routed endpoints and the internal
|
|
362
|
+
// transient-credential exchange.
|
|
363
|
+
_coreRequest (method, path, request) {
|
|
364
|
+
let config = {
|
|
365
|
+
method: method,
|
|
366
|
+
url: this._assembleCoreUrl(path),
|
|
367
|
+
timeout: this._getTimeout(request, this.gatewayTimeout) * 1000,
|
|
368
|
+
headers: {
|
|
369
|
+
[AGENT_HEADER]: USER_AGENT,
|
|
370
|
+
'Content-Type': 'application/json',
|
|
371
|
+
Authorization: `Bearer ${this.bearerToken}`,
|
|
372
|
+
},
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (method !== 'get') {
|
|
376
|
+
config.data = request
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return axios(config)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
_getTimeout (request, defaultTimeout) {
|
|
383
|
+
if (request && 'timeout' in request) {
|
|
384
|
+
return request['timeout']
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return defaultTimeout
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
_assembleDashboardUrl (path) {
|
|
391
|
+
return this.dashboardHost + path
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
_assembleCoreUrl (path) {
|
|
395
|
+
return this.coreHost + path
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
_assembleGatewayUrl (path, payload) {
|
|
399
|
+
let result = ''
|
|
400
|
+
if (payload && payload.test) {
|
|
401
|
+
result = result + this.testGatewayHost
|
|
402
|
+
} else {
|
|
403
|
+
result = result + this.gatewayHost
|
|
404
|
+
}
|
|
405
|
+
result = result + path
|
|
406
|
+
return result
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async _terminalRequest (method, route, path, request) {
|
|
410
|
+
let url = await this._assembleTerminalUrl(route, path)
|
|
411
|
+
|
|
412
|
+
let config = {
|
|
413
|
+
method: method,
|
|
414
|
+
url: url,
|
|
415
|
+
headers: {
|
|
416
|
+
[AGENT_HEADER]: USER_AGENT,
|
|
417
|
+
'Content-Type': 'application/json',
|
|
418
|
+
},
|
|
419
|
+
timeout: this._getTimeout(request, this.terminalTimeout) * 1000,
|
|
420
|
+
}
|
|
421
|
+
if (this.https) {
|
|
422
|
+
if (nodeHttps) {
|
|
423
|
+
config.httpsAgent = new nodeHttps.Agent({
|
|
424
|
+
rejectUnauthorized: false
|
|
425
|
+
})
|
|
426
|
+
} else {
|
|
427
|
+
config.httpsAgent = new browserifyHttps.Agent({
|
|
428
|
+
rejectUnauthorized: false
|
|
429
|
+
})
|
|
430
|
+
config.httpsAgent.protocol = 'https:'
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (request) {
|
|
435
|
+
config.data = {
|
|
436
|
+
apiKey: route.transientCredentials.apiKey,
|
|
437
|
+
bearerToken: route.transientCredentials.bearerToken,
|
|
438
|
+
signingKey: route.transientCredentials.signingKey,
|
|
439
|
+
request: request,
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return axios(config)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
_assembleTerminalUrl (route, path) {
|
|
447
|
+
let result = 'http'
|
|
448
|
+
if (this.https) {
|
|
449
|
+
result = result + 's'
|
|
450
|
+
}
|
|
451
|
+
result = result + '://'
|
|
452
|
+
result = result + route.ipAddress
|
|
453
|
+
if (this.https) {
|
|
454
|
+
result = result + ':8443'
|
|
455
|
+
} else {
|
|
456
|
+
result = result + ':8080'
|
|
457
|
+
}
|
|
458
|
+
result = result + path
|
|
459
|
+
return result
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// _populateSignatureOptions infers the signature image format from the
|
|
463
|
+
// requested file extension when the caller did not state one, and rejects a
|
|
464
|
+
// format the terminal cannot produce before the transaction is sent.
|
|
465
|
+
async _populateSignatureOptions (request) {
|
|
466
|
+
if (!request || !request.sigFile) {
|
|
467
|
+
return
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (!request.sigFormat) {
|
|
471
|
+
let parts = String(request.sigFile).split('.')
|
|
472
|
+
request.sigFormat = parts[parts.length - 1].toLowerCase()
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
let valid = [SignatureFormat.NONE, SignatureFormat.PNG, SignatureFormat.JPG, SignatureFormat.GIF]
|
|
476
|
+
|
|
477
|
+
if (valid.indexOf(request.sigFormat) < 0) {
|
|
478
|
+
throw new Error('invalid signature format: ' + request.sigFormat)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// _handleSignature writes the captured signature image to the file the caller
|
|
483
|
+
// asked for and clears it from the response, so the hex payload is not left
|
|
484
|
+
// in an object the caller is likely to log. Writing needs a filesystem, so in
|
|
485
|
+
// the browser the image is left on the response instead.
|
|
486
|
+
async _handleSignature (request, response) {
|
|
487
|
+
if (!request || !request.sigFile || !response || !response.sigFile) {
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
let fs = optionalNodeModule('fs')
|
|
492
|
+
if (!fs) {
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
let content = Buffer.from(response.sigFile, 'hex')
|
|
497
|
+
response.sigFile = ''
|
|
498
|
+
|
|
499
|
+
fs.writeFileSync(request.sigFile, content, { mode: 0o600 })
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// _offlineCacheFile is the path routes are persisted to, or undefined in the
|
|
503
|
+
// browser, where there is nothing to persist to.
|
|
504
|
+
_offlineCacheFile () {
|
|
505
|
+
let os = optionalNodeModule('os')
|
|
506
|
+
let path = optionalNodeModule('path')
|
|
507
|
+
if (!os || !path) {
|
|
508
|
+
return undefined
|
|
509
|
+
}
|
|
510
|
+
return path.join(os.tmpdir(), '.staxpayments_routes')
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// _routeCacheKey scopes a cached route to the credentials that resolved it, so
|
|
514
|
+
// rotating credentials cannot serve a route resolved under the previous set.
|
|
515
|
+
_routeCacheKey (terminalName) {
|
|
516
|
+
return (this.bcCredentials ? this.bcCredentials.apiKey : '') + terminalName
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// _deriveOfflineKey hashes the fixed key together with the current signing
|
|
520
|
+
// key. The cache is therefore readable only while the same credentials are
|
|
521
|
+
// held, and unreadable to anything else that finds the file.
|
|
522
|
+
_deriveOfflineKey () {
|
|
523
|
+
let signingKey = this.bcCredentials ? this.bcCredentials.signingKey : ''
|
|
524
|
+
let input = new Uint8Array([...hexToBytes(OFFLINE_FIXED_KEY), ...hexToBytes(signingKey)])
|
|
525
|
+
return sha256(input)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// AES/CBC/PKCS7 over the first 16 bytes of the derived key, hex encoded with
|
|
529
|
+
// the IV prefixed. The scheme is shared with the other Stax Payments SDKs.
|
|
530
|
+
_encrypt (value) {
|
|
531
|
+
let key = this._deriveOfflineKey().slice(0, 16)
|
|
532
|
+
let iv = randomBytes(16)
|
|
533
|
+
let cbc = new aesjs.ModeOfOperation.cbc(key, iv)
|
|
534
|
+
let encrypted = cbc.encrypt(aesjs.padding.pkcs7.pad(utf8ToBytes(value)))
|
|
535
|
+
return bytesToHex(iv) + bytesToHex(encrypted)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
_decrypt (value) {
|
|
539
|
+
let key = this._deriveOfflineKey().slice(0, 16)
|
|
540
|
+
let raw = hexToBytes(value)
|
|
541
|
+
let cbc = new aesjs.ModeOfOperation.cbc(key, raw.slice(0, 16))
|
|
542
|
+
let decrypted = aesjs.padding.pkcs7.strip(cbc.decrypt(raw.slice(16)))
|
|
543
|
+
return aesjs.utils.utf8.fromBytes(decrypted)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
_readOfflineCache () {
|
|
547
|
+
let fs = optionalNodeModule('fs')
|
|
548
|
+
let file = this._offlineCacheFile()
|
|
549
|
+
if (!fs || !file) {
|
|
550
|
+
return undefined
|
|
551
|
+
}
|
|
552
|
+
try {
|
|
553
|
+
if (!fs.existsSync(file)) {
|
|
554
|
+
return undefined
|
|
555
|
+
}
|
|
556
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
557
|
+
} catch (e) {
|
|
558
|
+
// An unreadable or corrupt cache is a missing cache, never a failed
|
|
559
|
+
// transaction.
|
|
560
|
+
return undefined
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// _readFromOfflineCache returns a persisted route. Credentials are decrypted
|
|
565
|
+
// on the way out. When stale is false an expired entry is ignored; when true
|
|
566
|
+
// it is served anyway, which is what keeps a terminal reachable while the
|
|
567
|
+
// gateway is not.
|
|
568
|
+
_readFromOfflineCache (terminalName, stale) {
|
|
569
|
+
let cache = this._readOfflineCache()
|
|
570
|
+
if (!cache || !cache.routes) {
|
|
571
|
+
return undefined
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
let entry = cache.routes[this._routeCacheKey(terminalName)]
|
|
575
|
+
if (!entry) {
|
|
576
|
+
return undefined
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (!stale && Date.parse(entry.TTL) <= Date.now()) {
|
|
580
|
+
return undefined
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
try {
|
|
584
|
+
let route = entry.Route
|
|
585
|
+
route.transientCredentials = {
|
|
586
|
+
apiKey: this._decrypt(route.transientCredentials.apiKey),
|
|
587
|
+
bearerToken: this._decrypt(route.transientCredentials.bearerToken),
|
|
588
|
+
signingKey: this._decrypt(route.transientCredentials.signingKey)
|
|
589
|
+
}
|
|
590
|
+
return route
|
|
591
|
+
} catch (e) {
|
|
592
|
+
// Written under different credentials, so it cannot be decrypted now.
|
|
593
|
+
return undefined
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
_updateOfflineCache (route, ttlMs) {
|
|
598
|
+
let fs = optionalNodeModule('fs')
|
|
599
|
+
let file = this._offlineCacheFile()
|
|
600
|
+
if (!fs || !file) {
|
|
601
|
+
return
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
let cache = this._readOfflineCache() || { routes: {} }
|
|
606
|
+
if (!cache.routes) {
|
|
607
|
+
cache.routes = {}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
cache.routes[this._routeCacheKey(route.terminalName)] = {
|
|
611
|
+
TTL: new Date(ttlMs).toISOString(),
|
|
612
|
+
Route: Object.assign({}, route, {
|
|
613
|
+
transientCredentials: {
|
|
614
|
+
apiKey: this._encrypt(route.transientCredentials.apiKey),
|
|
615
|
+
bearerToken: this._encrypt(route.transientCredentials.bearerToken),
|
|
616
|
+
signingKey: this._encrypt(route.transientCredentials.signingKey)
|
|
617
|
+
}
|
|
618
|
+
})
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
fs.writeFileSync(file, JSON.stringify(cache), { mode: 0o600 })
|
|
622
|
+
} catch (e) {
|
|
623
|
+
// Persisting is an optimization; the in-memory cache still stands.
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// _requestRouteFromGateway resolves a route and rejects anything that is not
|
|
628
|
+
// a usable one, so a failed lookup is never cached or routed on.
|
|
629
|
+
async _requestRouteFromGateway (terminalName) {
|
|
630
|
+
let routeResponse = await this._gatewayRequest(
|
|
631
|
+
'get', '/api/terminal-route?terminal=' + encodeURIComponent(terminalName))
|
|
632
|
+
let route = routeResponse.data
|
|
633
|
+
|
|
634
|
+
if (!route || route.success === false || !route.ipAddress) {
|
|
635
|
+
throw new Error('unknown terminal: ' + terminalName)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
route.exists = true
|
|
639
|
+
route.https = true
|
|
640
|
+
|
|
641
|
+
return route
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async _resolveTerminalRoute (terminalName) {
|
|
645
|
+
let key = this._routeCacheKey(terminalName)
|
|
646
|
+
let cacheEntry = this._routeCache[key]
|
|
647
|
+
|
|
648
|
+
if (cacheEntry && cacheEntry.ttl >= new Date().getTime()) {
|
|
649
|
+
return cacheEntry.route
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// An IP address addresses a terminal directly and needs no lookup.
|
|
653
|
+
if ((terminalName.match(/\./g) || []).length === 3) {
|
|
654
|
+
return {
|
|
655
|
+
terminalName: terminalName,
|
|
656
|
+
ipAddress: terminalName,
|
|
657
|
+
cloudRelayEnabled: false,
|
|
658
|
+
exists: true,
|
|
659
|
+
https: false,
|
|
660
|
+
transientCredentials: { apiKey: '', bearerToken: '', signingKey: '' }
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
let offline = this._readFromOfflineCache(terminalName, false)
|
|
665
|
+
if (offline) {
|
|
666
|
+
return offline
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
let route
|
|
670
|
+
try {
|
|
671
|
+
route = await this._requestRouteFromGateway(terminalName)
|
|
672
|
+
} catch (e) {
|
|
673
|
+
// The gateway is unreachable or does not know the terminal. A stale
|
|
674
|
+
// persisted route is better than no transaction.
|
|
675
|
+
let stale = this._readFromOfflineCache(terminalName, true)
|
|
676
|
+
if (stale) {
|
|
677
|
+
return stale
|
|
678
|
+
}
|
|
679
|
+
throw e
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
let ttl = new Date().getTime() + (this.routeCacheTTL * 60000)
|
|
683
|
+
this._routeCache[key] = { ttl: ttl, route: route }
|
|
684
|
+
this._updateOfflineCache(route, ttl)
|
|
685
|
+
|
|
686
|
+
return route
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// StaxApiCredentials is the Stax bearer token used to construct a client. It is
|
|
691
|
+
// the only credential an integrator supplies; the BlockChyp gateway credentials
|
|
692
|
+
// are obtained and managed internally by the SDK.
|
|
693
|
+
export class StaxApiCredentials {
|
|
694
|
+
constructor (bearerToken) {
|
|
695
|
+
this.bearerToken = bearerToken
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// StaxPaymentsCredentials models the BlockChyp gateway credentials the SDK
|
|
700
|
+
// obtains by exchanging the bearer token. It is internal to the SDK.
|
|
701
|
+
export class StaxPaymentsCredentials {
|
|
702
|
+
constructor (apiKey, bearerToken, signingKey) {
|
|
703
|
+
this.apiKey = apiKey
|
|
704
|
+
this.bearerToken = bearerToken
|
|
705
|
+
this.signingKey = signingKey
|
|
706
|
+
}
|
|
707
|
+
}
|