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