@nile-squad/nylonpay-ts 1.0.6 → 1.0.8

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/dist/index.cjs CHANGED
@@ -4,7 +4,7 @@ var crypto = require('crypto');
4
4
  var slangTs = require('slang-ts');
5
5
  var os = require('os');
6
6
 
7
- // src/sdk.ts
7
+ // src/create-nylon-pay.ts
8
8
 
9
9
  // src/pubsub.ts
10
10
  function createEmitter() {
@@ -63,9 +63,7 @@ var DEFAULT_MAX_RETRIES = 3;
63
63
  var DEFAULT_MAX_POLL_INTERVAL_MS = 2e3;
64
64
  var DEFAULT_MAX_POLL_DURATION_MS = 3e5;
65
65
  var DEFAULT_MAX_POLL_ATTEMPTS = 150;
66
- var DEFAULT_STREAMING = true;
67
- var STREAM_PATH = "/sse/transaction";
68
- var MAX_STREAM_RECONNECTS = 2;
66
+ var POLL_JITTER_MS = 250;
69
67
  var SDK_SERVICE = "sdk";
70
68
  var SDK_ACTIONS = {
71
69
  collectPayment: "sdk-collect-payment",
@@ -93,13 +91,22 @@ function generateFingerprint() {
93
91
  function generateNonce(length = 16) {
94
92
  return crypto.randomBytes(length).toString("hex");
95
93
  }
94
+ function compareByCodePoint(first, second) {
95
+ if (first < second) {
96
+ return -1;
97
+ }
98
+ if (first > second) {
99
+ return 1;
100
+ }
101
+ return 0;
102
+ }
96
103
  function sortValue(value) {
97
104
  if (Array.isArray(value)) {
98
105
  return value.map((entry) => sortValue(entry));
99
106
  }
100
107
  if (value && typeof value === "object") {
101
108
  const sortedEntries = Object.entries(value).sort(
102
- ([firstKey], [secondKey]) => firstKey.localeCompare(secondKey)
109
+ ([firstKey], [secondKey]) => compareByCodePoint(firstKey, secondKey)
103
110
  );
104
111
  return Object.fromEntries(
105
112
  sortedEntries.map(([entryKey, entryValue]) => [
@@ -123,42 +130,6 @@ function createSignature(input) {
123
130
  function createTimestamp() {
124
131
  return Date.now().toString();
125
132
  }
126
-
127
- // src/sse-parse.ts
128
- function parseBlock(block) {
129
- let event = "message";
130
- const dataLines = [];
131
- for (const rawLine of block.split("\n")) {
132
- const line = rawLine.replace(/\r$/, "");
133
- if (line.startsWith(":")) {
134
- continue;
135
- }
136
- if (line.startsWith("event:")) {
137
- event = line.slice("event:".length).trim();
138
- } else if (line.startsWith("data:")) {
139
- dataLines.push(line.slice("data:".length).trim());
140
- }
141
- }
142
- if (dataLines.length === 0) {
143
- return null;
144
- }
145
- return { event, data: dataLines.join("\n") };
146
- }
147
- function parseSseBuffer(buffer) {
148
- const messages = [];
149
- let rest = buffer;
150
- let separator = rest.indexOf("\n\n");
151
- while (separator !== -1) {
152
- const block = rest.slice(0, separator);
153
- rest = rest.slice(separator + 2);
154
- const message = parseBlock(block);
155
- if (message) {
156
- messages.push(message);
157
- }
158
- separator = rest.indexOf("\n\n");
159
- }
160
- return { messages, rest };
161
- }
162
133
  function verifyResponseSignature(data, signature, secret) {
163
134
  const expectedSignature = crypto.createHmac("sha256", secret).update(createCanonicalPayload(data)).digest("hex");
164
135
  const providedBuffer = Buffer.from(signature, "hex");
@@ -171,13 +142,6 @@ function verifyResponseSignature(data, signature, secret) {
171
142
 
172
143
  // src/transport.ts
173
144
  var CACHED_FINGERPRINT = generateFingerprint();
174
- function streamUrl(baseUrl) {
175
- try {
176
- return new URL(baseUrl).origin + STREAM_PATH;
177
- } catch {
178
- return baseUrl.replace(/\/api\/services\/?$/u, "") + STREAM_PATH;
179
- }
180
- }
181
145
  var KNOWN_CATEGORIES = /* @__PURE__ */ new Set([
182
146
  "auth",
183
147
  "validation",
@@ -343,22 +307,30 @@ function createTransport({
343
307
  const { status, message, data } = responseBody;
344
308
  if (status === true) {
345
309
  const { data: strippedData, responseSignature } = stripResponseSignature(data);
346
- if (responseSignature) {
347
- const isValid = verifyResponseSignature(
348
- strippedData,
349
- responseSignature,
350
- apiSecret
310
+ if (!responseSignature) {
311
+ cleanup();
312
+ return slangTs.Err(
313
+ JSON.stringify({
314
+ category: "internal",
315
+ message: "Response signature missing",
316
+ retryable: false
317
+ })
318
+ );
319
+ }
320
+ const isValid = verifyResponseSignature(
321
+ strippedData,
322
+ responseSignature,
323
+ apiSecret
324
+ );
325
+ if (!isValid) {
326
+ cleanup();
327
+ return slangTs.Err(
328
+ JSON.stringify({
329
+ category: "internal",
330
+ message: "Response signature verification failed",
331
+ retryable: false
332
+ })
351
333
  );
352
- if (!isValid) {
353
- cleanup();
354
- return slangTs.Err(
355
- JSON.stringify({
356
- category: "internal",
357
- message: "Response signature verification failed",
358
- retryable: false
359
- })
360
- );
361
- }
362
334
  }
363
335
  cleanup();
364
336
  return slangTs.Ok(strippedData);
@@ -383,91 +355,7 @@ function createTransport({
383
355
  }
384
356
  return attempt(0);
385
357
  }
386
- function openStream(input) {
387
- const controller = new AbortController();
388
- let closed = false;
389
- const close = () => {
390
- if (closed) {
391
- return;
392
- }
393
- closed = true;
394
- controller.abort();
395
- };
396
- const payload = {
397
- reference: input.reference,
398
- _fingerprint: CACHED_FINGERPRINT
399
- };
400
- const headers = buildAuthHeaders({ apiKey, apiSecret, payload });
401
- const url = streamUrl(baseUrl);
402
- const run = async () => {
403
- const fetchResult = await slangTs.safeTry(
404
- () => fetchImpl(url, {
405
- method: "POST",
406
- headers,
407
- body: JSON.stringify(payload),
408
- signal: controller.signal
409
- })
410
- );
411
- if (fetchResult.isErr) {
412
- if (!closed) {
413
- input.onError(fetchResult.error);
414
- }
415
- return;
416
- }
417
- const response = fetchResult.value;
418
- if (!response.ok || !response.body) {
419
- const textResult = await slangTs.safeTry(() => response.text());
420
- const message = textResult.isOk ? textResult.value : `HTTP ${response.status}`;
421
- if (!closed) {
422
- input.onError(message || `HTTP ${response.status}`);
423
- }
424
- return;
425
- }
426
- const reader = response.body.getReader();
427
- const decoder = new TextDecoder();
428
- let buffer = "";
429
- while (!closed) {
430
- const chunkResult = await slangTs.safeTry(() => reader.read());
431
- if (chunkResult.isErr) {
432
- if (!closed) {
433
- input.onError(chunkResult.error);
434
- }
435
- return;
436
- }
437
- const chunk = chunkResult.value;
438
- if (chunk.done) {
439
- break;
440
- }
441
- buffer += decoder.decode(chunk.value, { stream: true });
442
- const { messages, rest } = parseSseBuffer(buffer);
443
- buffer = rest;
444
- for (const message of messages) {
445
- if (message.event === "status") {
446
- const statusResult = await slangTs.safeTry(
447
- () => JSON.parse(message.data)
448
- );
449
- if (statusResult.isOk) {
450
- input.onStatus(statusResult.value);
451
- }
452
- } else if (message.event === "error") {
453
- const errDataResult = await slangTs.safeTry(
454
- () => JSON.parse(message.data)
455
- );
456
- const text = errDataResult.isOk ? errDataResult.value.message ?? message.data : message.data;
457
- close();
458
- input.onError(text);
459
- return;
460
- }
461
- }
462
- }
463
- if (!closed) {
464
- input.onClose();
465
- }
466
- };
467
- void run();
468
- return { close };
469
- }
470
- return { send, openStream, parseError };
358
+ return { send, parseError };
471
359
  }
472
360
  function parseError(error) {
473
361
  try {
@@ -517,22 +405,20 @@ function createPaymentInstance(initialResponse, deps) {
517
405
  fetchTransaction: deps.fetchTransaction,
518
406
  pollIntervalMs: deps.pollIntervalMs ?? 2e3,
519
407
  maxPollDuration: deps.maxPollDuration ?? 3e5,
520
- maxPollAttempts: deps.maxPollAttempts ?? 150,
521
- streaming: deps.streaming ?? false,
522
- openStream: deps.openStream,
523
- streamHandle: null,
524
- streamReconnects: 0
408
+ maxPollAttempts: deps.maxPollAttempts ?? 150
525
409
  };
526
410
  function resolveWithError(error) {
527
411
  state.resolved = true;
528
412
  stopUpdates();
529
413
  emitEvent("error", parseError(error).message);
530
414
  }
531
- function emitEvent(event, error) {
415
+ function emitEvent(event, error, category, retryable) {
532
416
  const data = {
533
417
  event,
534
418
  transaction: state.transaction ?? void 0,
535
419
  error,
420
+ category,
421
+ retryable,
536
422
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
537
423
  };
538
424
  state.emitter.emit(event, data);
@@ -555,6 +441,9 @@ function createPaymentInstance(initialResponse, deps) {
555
441
  stopUpdates();
556
442
  }
557
443
  async function handleStatusUpdate(response) {
444
+ if (state.resolved) {
445
+ return;
446
+ }
558
447
  if (response.reference !== state.reference) {
559
448
  resolveWithError(
560
449
  `Reference mismatch: expected ${state.reference} but got ${response.reference}`
@@ -588,10 +477,11 @@ function createPaymentInstance(initialResponse, deps) {
588
477
  if (state.resolved || state.pollingTimer) {
589
478
  return;
590
479
  }
480
+ const delay2 = state.pollIntervalMs + Math.random() * POLL_JITTER_MS;
591
481
  state.pollingTimer = setTimeout(() => {
592
482
  state.pollingTimer = null;
593
483
  void pollStatus();
594
- }, state.pollIntervalMs);
484
+ }, delay2);
595
485
  }
596
486
  async function pollStatus() {
597
487
  if (state.resolved) {
@@ -619,42 +509,6 @@ function createPaymentInstance(initialResponse, deps) {
619
509
  }
620
510
  scheduleNextPoll();
621
511
  }
622
- function closeStream() {
623
- if (state.streamHandle) {
624
- state.streamHandle.close();
625
- state.streamHandle = null;
626
- }
627
- }
628
- function startStream() {
629
- if (state.resolved || !state.openStream) {
630
- return;
631
- }
632
- state.streamHandle = state.openStream({
633
- reference: state.reference,
634
- onStatus: (status) => {
635
- void handleStatusUpdate(status);
636
- },
637
- onError: () => handleStreamFailure(),
638
- onClose: () => handleStreamFailure()
639
- });
640
- }
641
- function handleStreamFailure() {
642
- closeStream();
643
- if (state.resolved) {
644
- return;
645
- }
646
- if (state.streamReconnects < MAX_STREAM_RECONNECTS) {
647
- state.streamReconnects += 1;
648
- const backoff = 500 * 2 ** (state.streamReconnects - 1) + Math.random() * 250;
649
- setTimeout(() => {
650
- if (!state.resolved) {
651
- startStream();
652
- }
653
- }, backoff);
654
- return;
655
- }
656
- scheduleNextPoll();
657
- }
658
512
  function startUpdates() {
659
513
  if (TERMINAL_STATES.has(state.status)) {
660
514
  setTimeout(() => {
@@ -662,10 +516,6 @@ function createPaymentInstance(initialResponse, deps) {
662
516
  }, 0);
663
517
  return;
664
518
  }
665
- if (state.streaming && state.openStream) {
666
- startStream();
667
- return;
668
- }
669
519
  scheduleNextPoll();
670
520
  }
671
521
  function stopUpdates() {
@@ -673,7 +523,6 @@ function createPaymentInstance(initialResponse, deps) {
673
523
  clearTimeout(state.pollingTimer);
674
524
  state.pollingTimer = null;
675
525
  }
676
- closeStream();
677
526
  }
678
527
  function on(event, handler) {
679
528
  state.emitter.on(event, handler);
@@ -735,24 +584,76 @@ function createPaymentInstance(initialResponse, deps) {
735
584
  off,
736
585
  wait
737
586
  };
738
- startUpdates();
587
+ if (deps.initialError) {
588
+ state.resolved = true;
589
+ const err = deps.initialError;
590
+ setTimeout(() => {
591
+ emitEvent("error", err.message, err.category, err.retryable);
592
+ }, 0);
593
+ } else {
594
+ startUpdates();
595
+ }
739
596
  return paymentInstance;
740
597
  }
598
+ var DEFAULT_TOLERANCE_SECONDS = 300;
599
+ function decodePayload(payload) {
600
+ return typeof payload === "string" ? payload : Buffer.from(payload).toString("utf8");
601
+ }
602
+ function extractSignedTimestampMs(payloadString) {
603
+ let parsed;
604
+ try {
605
+ parsed = JSON.parse(payloadString);
606
+ } catch {
607
+ return null;
608
+ }
609
+ if (!parsed || typeof parsed !== "object") {
610
+ return null;
611
+ }
612
+ const raw = parsed.timestamp;
613
+ if (typeof raw === "number" && Number.isFinite(raw)) {
614
+ return raw < 1e12 ? raw * 1e3 : raw;
615
+ }
616
+ if (typeof raw === "string") {
617
+ const ms = Date.parse(raw);
618
+ return Number.isNaN(ms) ? null : ms;
619
+ }
620
+ return null;
621
+ }
741
622
  function verifyWebhookSignature(input) {
742
- const payloadBytes = typeof input.payload === "string" ? Buffer.from(input.payload, "utf8") : Buffer.from(input.payload);
623
+ const payloadString = decodePayload(input.payload);
624
+ const payloadBytes = Buffer.from(payloadString, "utf8");
743
625
  const expectedSignature = crypto.createHmac("sha256", input.secret).update(payloadBytes).digest("hex");
744
626
  const providedBuffer = Buffer.from(input.signature, "hex");
745
627
  const expectedBuffer = Buffer.from(expectedSignature, "hex");
746
628
  if (providedBuffer.length !== expectedBuffer.length) {
747
629
  return false;
748
630
  }
749
- return crypto.timingSafeEqual(providedBuffer, expectedBuffer);
631
+ if (!crypto.timingSafeEqual(providedBuffer, expectedBuffer)) {
632
+ return false;
633
+ }
634
+ const toleranceSeconds = input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
635
+ if (toleranceSeconds <= 0) {
636
+ return true;
637
+ }
638
+ const timestampMs = extractSignedTimestampMs(payloadString);
639
+ if (timestampMs === null) {
640
+ return false;
641
+ }
642
+ const ageMs = Math.abs(Date.now() - timestampMs);
643
+ return ageMs <= toleranceSeconds * 1e3;
750
644
  }
751
645
 
752
646
  // src/sdk.ts
753
647
  function generateReference() {
754
648
  return crypto.randomBytes(16).toString("hex").slice(0, 15);
755
649
  }
650
+ async function runHook(hook, ...args) {
651
+ if (!hook || hook.enabled === false) return void 0;
652
+ const result = await slangTs.safeTry(async () => hook.fn(...args));
653
+ if (result.isOk) return result.value;
654
+ await slangTs.safeTry(async () => hook.onError(result.error));
655
+ return void 0;
656
+ }
756
657
  function throwValidation(message) {
757
658
  throw createSdkError({ category: "validation", message });
758
659
  }
@@ -786,9 +687,7 @@ function createSdkInstance(config) {
786
687
  }),
787
688
  pollIntervalMs: config.maxPollIntervalMs,
788
689
  maxPollDuration: config.maxPollDurationMs,
789
- maxPollAttempts: config.maxPollAttempts,
790
- streaming: config.streaming,
791
- openStream: config.streaming ? transport.openStream : void 0
690
+ maxPollAttempts: config.maxPollAttempts
792
691
  };
793
692
  async function collectPayment(input) {
794
693
  const reference = input.reference ?? generateReference();
@@ -800,26 +699,24 @@ function createSdkInstance(config) {
800
699
  throwValidation('bank details are required when method is "bank"');
801
700
  }
802
701
  let payload = { ...input, reference };
803
- if (config.hooks?.beforeCollect) {
804
- const mutated = await config.hooks.beforeCollect(payload);
805
- if (mutated != null)
806
- payload = { ...mutated, reference: mutated.reference ?? reference };
807
- }
702
+ const mutated = await runHook(config.hooks?.beforeCollect, payload);
703
+ if (mutated != null)
704
+ payload = { ...mutated, reference: mutated.reference ?? reference };
808
705
  const result = await transport.send({
809
706
  action: SDK_ACTIONS.collectPayment,
810
707
  payload
811
708
  });
812
- if (config.hooks?.afterCollect) {
813
- await config.hooks.afterCollect(
814
- result.isOk ? slangTs.Ok({
815
- reference: result.value.reference,
816
- status: result.value.status
817
- }) : slangTs.Err(result.error),
818
- payload
819
- );
820
- }
709
+ await runHook(
710
+ config.hooks?.afterCollect,
711
+ result.isOk ? slangTs.Ok({ reference: result.value.reference, status: result.value.status }) : slangTs.Err(result.error),
712
+ payload
713
+ );
821
714
  if (result.isErr) {
822
- throw createSdkError(parseError(result.error));
715
+ const sdkErr = parseError(result.error);
716
+ return createPaymentInstance(
717
+ { reference, status: "pending" },
718
+ { ...commonDeps, initialError: sdkErr }
719
+ );
823
720
  }
824
721
  return createPaymentInstance(result.value, commonDeps);
825
722
  }
@@ -833,24 +730,18 @@ function createSdkInstance(config) {
833
730
  throwValidation('bank details are required when method is "bank"');
834
731
  }
835
732
  let payload = { ...input, reference };
836
- if (config.hooks?.beforeCollect) {
837
- const mutated = await config.hooks.beforeCollect(payload);
838
- if (mutated != null)
839
- payload = { ...mutated, reference: mutated.reference ?? reference };
840
- }
733
+ const mutated = await runHook(config.hooks?.beforeCollect, payload);
734
+ if (mutated != null)
735
+ payload = { ...mutated, reference: mutated.reference ?? reference };
841
736
  const result = await transport.send({
842
737
  action: SDK_ACTIONS.collectPaymentAndResolve,
843
738
  payload
844
739
  });
845
- if (config.hooks?.afterCollect) {
846
- await config.hooks.afterCollect(
847
- result.isOk ? slangTs.Ok({
848
- reference: result.value.reference,
849
- status: result.value.status
850
- }) : slangTs.Err(result.error),
851
- payload
852
- );
853
- }
740
+ await runHook(
741
+ config.hooks?.afterCollect,
742
+ result.isOk ? slangTs.Ok({ reference: result.value.reference, status: result.value.status }) : slangTs.Err(result.error),
743
+ payload
744
+ );
854
745
  if (result.isOk) {
855
746
  return slangTs.Ok(result.value);
856
747
  }
@@ -871,26 +762,24 @@ function createSdkInstance(config) {
871
762
  "destination.accountNumber"
872
763
  );
873
764
  let payload = { ...input, reference };
874
- if (config.hooks?.beforePayout) {
875
- const mutated = await config.hooks.beforePayout(payload);
876
- if (mutated != null)
877
- payload = { ...mutated, reference: mutated.reference ?? reference };
878
- }
765
+ const mutated = await runHook(config.hooks?.beforePayout, payload);
766
+ if (mutated != null)
767
+ payload = { ...mutated, reference: mutated.reference ?? reference };
879
768
  const result = await transport.send({
880
769
  action: SDK_ACTIONS.makePayout,
881
770
  payload
882
771
  });
883
- if (config.hooks?.afterPayout) {
884
- await config.hooks.afterPayout(
885
- result.isOk ? slangTs.Ok({
886
- reference: result.value.reference,
887
- status: result.value.status
888
- }) : slangTs.Err(result.error),
889
- payload
890
- );
891
- }
772
+ await runHook(
773
+ config.hooks?.afterPayout,
774
+ result.isOk ? slangTs.Ok({ reference: result.value.reference, status: result.value.status }) : slangTs.Err(result.error),
775
+ payload
776
+ );
892
777
  if (result.isErr) {
893
- throw createSdkError(parseError(result.error));
778
+ const sdkErr = parseError(result.error);
779
+ return createPaymentInstance(
780
+ { reference, status: "pending" },
781
+ { ...commonDeps, initialError: sdkErr }
782
+ );
894
783
  }
895
784
  return createPaymentInstance(result.value, commonDeps);
896
785
  }
@@ -909,24 +798,18 @@ function createSdkInstance(config) {
909
798
  "destination.accountNumber"
910
799
  );
911
800
  let payload = { ...input, reference };
912
- if (config.hooks?.beforePayout) {
913
- const mutated = await config.hooks.beforePayout(payload);
914
- if (mutated != null)
915
- payload = { ...mutated, reference: mutated.reference ?? reference };
916
- }
801
+ const mutated = await runHook(config.hooks?.beforePayout, payload);
802
+ if (mutated != null)
803
+ payload = { ...mutated, reference: mutated.reference ?? reference };
917
804
  const result = await transport.send({
918
805
  action: SDK_ACTIONS.makePayoutAndResolve,
919
806
  payload
920
807
  });
921
- if (config.hooks?.afterPayout) {
922
- await config.hooks.afterPayout(
923
- result.isOk ? slangTs.Ok({
924
- reference: result.value.reference,
925
- status: result.value.status
926
- }) : slangTs.Err(result.error),
927
- payload
928
- );
929
- }
808
+ await runHook(
809
+ config.hooks?.afterPayout,
810
+ result.isOk ? slangTs.Ok({ reference: result.value.reference, status: result.value.status }) : slangTs.Err(result.error),
811
+ payload
812
+ );
930
813
  if (result.isOk) {
931
814
  return slangTs.Ok(result.value);
932
815
  }
@@ -1026,7 +909,8 @@ function createNylonPay(config) {
1026
909
  throw new Error('apiSecret must start with "nps_"');
1027
910
  }
1028
911
  const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
1029
- const instanceKey = `${config.apiKey}:${baseUrl}`;
912
+ const secretHash = crypto.createHash("sha256").update(config.apiSecret).digest("hex").slice(0, 16);
913
+ const instanceKey = `${config.apiKey}:${baseUrl}:${secretHash}`;
1030
914
  if (!config.force) {
1031
915
  const existing = instances.get(instanceKey);
1032
916
  if (existing) return existing;
@@ -1040,7 +924,6 @@ function createNylonPay(config) {
1040
924
  maxPollIntervalMs: config.maxPollIntervalMs ?? DEFAULT_MAX_POLL_INTERVAL_MS,
1041
925
  maxPollDurationMs: config.maxPollDurationMs ?? DEFAULT_MAX_POLL_DURATION_MS,
1042
926
  maxPollAttempts: config.maxPollAttempts ?? DEFAULT_MAX_POLL_ATTEMPTS,
1043
- streaming: config.streaming ?? DEFAULT_STREAMING,
1044
927
  fetch: config.fetch ?? globalThis.fetch.bind(globalThis),
1045
928
  hooks: config.hooks
1046
929
  };