@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/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
+ }