@laranex/myanmar-payments 4.0.0-dev.2 → 4.0.0-dev.4

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +1 -1
  3. package/dist/cjs/aya-pay/aya-pay.d.ts +5 -2
  4. package/dist/cjs/aya-pay/aya-pay.js +7 -2
  5. package/dist/cjs/core/amount.d.ts +4 -3
  6. package/dist/cjs/core/amount.js +13 -4
  7. package/dist/cjs/core/callback.d.ts +14 -8
  8. package/dist/cjs/core/callback.js +19 -11
  9. package/dist/cjs/core/crypto.d.ts +6 -1
  10. package/dist/cjs/core/crypto.js +17 -3
  11. package/dist/cjs/core/env.d.ts +7 -0
  12. package/dist/cjs/core/env.js +14 -2
  13. package/dist/cjs/core/json.d.ts +2 -2
  14. package/dist/cjs/core/json.js +3 -3
  15. package/dist/cjs/core/status.d.ts +3 -3
  16. package/dist/cjs/core/status.js +2 -2
  17. package/dist/cjs/core/values.d.ts +2 -0
  18. package/dist/cjs/core/values.js +5 -0
  19. package/dist/cjs/cyber-source/cyber-source.d.ts +7 -3
  20. package/dist/cjs/cyber-source/cyber-source.js +27 -13
  21. package/dist/cjs/kbz-pay/kbz-pay.d.ts +6 -2
  22. package/dist/cjs/kbz-pay/kbz-pay.js +2 -2
  23. package/dist/cjs/kbz-pay/signer.d.ts +4 -1
  24. package/dist/cjs/kbz-pay/signer.js +7 -2
  25. package/dist/cjs/wave-money/wave-money.d.ts +6 -3
  26. package/dist/cjs/wave-money/wave-money.js +6 -4
  27. package/dist/cjs/yoma-mmqr/yoma-mmqr.d.ts +5 -2
  28. package/dist/cjs/yoma-mmqr/yoma-mmqr.js +8 -5
  29. package/dist/esm/aya-pay/aya-pay.d.ts +5 -2
  30. package/dist/esm/aya-pay/aya-pay.js +9 -4
  31. package/dist/esm/core/amount.d.ts +4 -3
  32. package/dist/esm/core/amount.js +13 -4
  33. package/dist/esm/core/callback.d.ts +14 -8
  34. package/dist/esm/core/callback.js +19 -11
  35. package/dist/esm/core/crypto.d.ts +6 -1
  36. package/dist/esm/core/crypto.js +17 -3
  37. package/dist/esm/core/env.d.ts +7 -0
  38. package/dist/esm/core/env.js +13 -2
  39. package/dist/esm/core/json.d.ts +2 -2
  40. package/dist/esm/core/json.js +3 -3
  41. package/dist/esm/core/status.d.ts +3 -3
  42. package/dist/esm/core/status.js +2 -2
  43. package/dist/esm/core/values.d.ts +2 -0
  44. package/dist/esm/core/values.js +4 -0
  45. package/dist/esm/cyber-source/cyber-source.d.ts +7 -3
  46. package/dist/esm/cyber-source/cyber-source.js +29 -15
  47. package/dist/esm/kbz-pay/kbz-pay.d.ts +6 -2
  48. package/dist/esm/kbz-pay/kbz-pay.js +3 -3
  49. package/dist/esm/kbz-pay/signer.d.ts +4 -1
  50. package/dist/esm/kbz-pay/signer.js +8 -3
  51. package/dist/esm/wave-money/wave-money.d.ts +6 -3
  52. package/dist/esm/wave-money/wave-money.js +8 -6
  53. package/dist/esm/yoma-mmqr/yoma-mmqr.d.ts +5 -2
  54. package/dist/esm/yoma-mmqr/yoma-mmqr.js +10 -7
  55. package/package.json +1 -1
  56. package/skills/node-myanmar-payments/SKILL.md +22 -20
@@ -40,7 +40,7 @@ class WaveMoneyConfig {
40
40
  ttl !== undefined && Number.isInteger(ttl) && ttl > 0
41
41
  ? ttl
42
42
  : WaveMoneyConfig.DEFAULT_TIME_TO_LIVE_SECONDS;
43
- this.sandbox = options.sandbox ?? true;
43
+ this.sandbox = (0, env_js_1.parseSandbox)(options.sandbox);
44
44
  this.baseUrl = (0, env_js_1.trimUrl)((0, env_js_1.optionalSetting)(options.baseUrl) ??
45
45
  (this.sandbox ? WaveMoneyConfig.SANDBOX_URL : WaveMoneyConfig.PRODUCTION_URL));
46
46
  this.authenticateUrl = (0, env_js_1.trimUrl)((0, env_js_1.optionalSetting)(options.authenticateUrl) ??
@@ -71,7 +71,7 @@ const STATUSES = {
71
71
  INSUFFICIENT_BALANCE: 'pending',
72
72
  ACCOUNT_LOCKED: 'failed',
73
73
  BILL_COLLECTION_FAILED: 'failed',
74
- PAYMENT_REQUEST_CANCELLED: 'cancelled',
74
+ PAYMENT_REQUEST_CANCELLED: 'canceled',
75
75
  TRANSACTION_TIMED_OUT: 'expired',
76
76
  SCHEDULER_TRANSACTION_TIMED_OUT: 'expired',
77
77
  };
@@ -209,13 +209,15 @@ class WaveMoney {
209
209
  /**
210
210
  * Verifies Wave's callback. Only `PAYMENT_CONFIRMED` means the customer paid. `orderId` falls
211
211
  * back to `merchantReferenceId` when it is missing, null or empty, because Wave marks `orderId` as
212
- * optional.
212
+ * optional. A hashed field holding an object or array fails verification.
213
213
  */
214
214
  handleCallback(request) {
215
215
  const payload = (0, callback_js_1.losslessBody)(request);
216
216
  const expected = this.hash(CALLBACK_FIELDS.map((field) => (0, values_js_1.scalarString)(payload[field]) ?? 'null'));
217
217
  const hashValue = payload.hashValue;
218
- if (typeof hashValue !== 'string' || !(0, crypto_js_1.safeEqual)(expected, hashValue.toLowerCase())) {
218
+ if (typeof hashValue !== 'string' ||
219
+ CALLBACK_FIELDS.some((field) => (0, values_js_1.isNested)(payload[field])) ||
220
+ !(0, crypto_js_1.safeEqual)(expected, hashValue.toLowerCase())) {
219
221
  throw new errors_js_1.SignatureVerificationError('Wave Money callback hash verification failed.', (0, json_js_1.toPlainObject)(payload));
220
222
  }
221
223
  const gatewayStatus = (0, values_js_1.trimmed)(payload, 'status');
@@ -16,8 +16,11 @@ export interface YomaMmqrConfigOptions {
16
16
  webhookHashKey: string;
17
17
  /** The secret you shared with Yoma; when set, callbacks must carry it in `X-Webhook-Secret`. */
18
18
  webhookSecret?: string | undefined;
19
- /** Use the UAT payment hub (default `true`). */
20
- sandbox?: boolean | undefined;
19
+ /**
20
+ * Use the UAT payment hub (default `true`).
21
+ * Text is read like a `*_SANDBOX` variable: `false`, `0`, `f`, `no` or `off` select production.
22
+ */
23
+ sandbox?: boolean | string | undefined;
21
24
  /** Overrides the API base URL. */
22
25
  baseUrl?: string | undefined;
23
26
  /** The `{version}` segment of the API paths (default `v1rc`). */
@@ -35,7 +35,7 @@ class YomaMmqrConfig {
35
35
  this.clientSecret = (0, env_js_1.requireSetting)('yoma_mmqr', 'client_secret', options.clientSecret);
36
36
  this.webhookHashKey = (0, env_js_1.requireSetting)('yoma_mmqr', 'webhook_hashkey', options.webhookHashKey);
37
37
  this.webhookSecret = (0, env_js_1.optionalSetting)(options.webhookSecret);
38
- this.sandbox = options.sandbox ?? true;
38
+ this.sandbox = (0, env_js_1.parseSandbox)(options.sandbox);
39
39
  this.baseUrl = (0, env_js_1.trimUrl)((0, env_js_1.optionalSetting)(options.baseUrl) ??
40
40
  (this.sandbox ? YomaMmqrConfig.SANDBOX_URL : YomaMmqrConfig.PRODUCTION_URL));
41
41
  this.apiVersion = (0, env_js_1.optionalSetting)(options.apiVersion) ?? YomaMmqrConfig.DEFAULT_API_VERSION;
@@ -161,7 +161,9 @@ class YomaMmqr {
161
161
  const orderNumber = (0, values_js_1.get)(payload, 'orderNumber');
162
162
  const status = (0, values_js_1.trimmed)(payload, 'status');
163
163
  const expected = (0, crypto_js_1.hmacSha256Hex)(orderNumber + this.config.webhookHashKey, `orderNumber=${orderNumber}&status=${status}`);
164
- if (orderNumber === '' || !(0, crypto_js_1.safeEqual)(expected, (0, values_js_1.get)(payload, 'hashValue').toLowerCase())) {
164
+ if (orderNumber === '' ||
165
+ (0, values_js_1.isNested)(payload.status) ||
166
+ !(0, crypto_js_1.safeEqual)(expected, (0, values_js_1.get)(payload, 'hashValue').toLowerCase())) {
165
167
  throw new errors_js_1.SignatureVerificationError('Yoma MMQR callback hash verification failed.', (0, json_js_1.toPlainObject)(payload));
166
168
  }
167
169
  return new callback_js_1.PaymentCallback({
@@ -210,13 +212,14 @@ class YomaMmqr {
210
212
  if (!response.successful() || token === '') {
211
213
  throw apiError('token', response.status, body, (0, values_js_1.get)(body, 'error'));
212
214
  }
213
- const expiresIn = Number.parseInt((0, values_js_1.get)(body, 'expires_in'), 10);
214
- const ttl = Math.max(60, (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600) - 60);
215
+ // The leading digits of `expires_in`; missing, non-numeric or 0 means an hour.
216
+ const expiresIn = Number(/^\d+/.exec((0, values_js_1.get)(body, 'expires_in'))?.[0] ?? 0);
217
+ const ttl = Math.max(60, (expiresIn > 0 ? expiresIn : 3600) - 60);
215
218
  await this.cache.set(this.tokenCacheKey(), token, ttl);
216
219
  return token;
217
220
  }
218
221
  tokenCacheKey() {
219
- return `node-myanmar-payments.yoma-mmqr.token.${(0, crypto_js_1.sha256Hex)(`${this.config.baseUrl}|${this.config.clientId}`)}`;
222
+ return `myanmar-payments.yoma-mmqr.token.${(0, crypto_js_1.sha256Hex)(`${this.config.baseUrl}|${this.config.clientId}`)}`;
220
223
  }
221
224
  }
222
225
  exports.YomaMmqr = YomaMmqr;
@@ -9,8 +9,11 @@ export interface AyaPayConfigOptions {
9
9
  appKey: string;
10
10
  /** The secret that signs requests and verifies callbacks. */
11
11
  appSecret: string;
12
- /** Use the UAT environment (default `true`). */
13
- sandbox?: boolean | undefined;
12
+ /**
13
+ * Use the UAT environment (default `true`).
14
+ * Text is read like a `*_SANDBOX` variable: `false`, `0`, `f`, `no` or `off` select production.
15
+ */
16
+ sandbox?: boolean | string | undefined;
14
17
  /** Overrides the gateway base URL. */
15
18
  baseUrl?: string | undefined;
16
19
  }
@@ -1,14 +1,14 @@
1
1
  import {} from '../core/amount.js';
2
2
  import { CallbackRequest, losslessInput, losslessQueryInput, PaymentCallback, PaymentStatusResult, } from '../core/callback.js';
3
3
  import { decodeBase64, hmacSha256Hex, safeEqual } from '../core/crypto.js';
4
- import { defaultEnv, envFirst, envSandbox, optionalSetting, requireSetting, trimUrl, } from '../core/env.js';
4
+ import { defaultEnv, envFirst, envSandbox, parseSandbox, optionalSetting, requireSetting, trimUrl, } from '../core/env.js';
5
5
  import { ApiError, SignatureVerificationError } from '../core/errors.js';
6
6
  import { httpClientFrom, Transport, } from '../core/http.js';
7
7
  import { isLosslessObject, parseJsonObject, toPlainObject, } from '../core/json.js';
8
8
  import { FormPayment } from '../core/results.js';
9
9
  import { resolveStatus } from '../core/status.js';
10
10
  import { toAmount, Validator } from '../core/validate.js';
11
- import { get, object, optional, scalarString, trimmed } from '../core/values.js';
11
+ import { get, isNested, object, optional, scalarString, trimmed } from '../core/values.js';
12
12
  /**
13
13
  * AYA Payment Gateway (APG) credentials and endpoints. A missing credential throws a
14
14
  * `ConfigurationError`.
@@ -24,7 +24,7 @@ export class AyaPayConfig {
24
24
  constructor(options) {
25
25
  this.appKey = requireSetting('aya_pay', 'app_key', options.appKey);
26
26
  this.appSecret = requireSetting('aya_pay', 'app_secret', options.appSecret);
27
- this.sandbox = options.sandbox ?? true;
27
+ this.sandbox = parseSandbox(options.sandbox);
28
28
  this.baseUrl = trimUrl(optionalSetting(options.baseUrl) ??
29
29
  (this.sandbox ? AyaPayConfig.SANDBOX_URL : AyaPayConfig.PRODUCTION_URL));
30
30
  }
@@ -242,7 +242,8 @@ export class AyaPay {
242
242
  /** Decodes a `payload` + `checkSum` pair and returns the payload if the checksum matches. */
243
243
  verifiedPayload(input, context) {
244
244
  const fail = () => new SignatureVerificationError(`AYA Pay ${context} checksum verification failed.`, toPlainObject(input));
245
- const json = decodeBase64(get(input, 'payload'));
245
+ // Base64 never contains spaces: a space is a `+` that an unencoded query string turned into one.
246
+ const json = decodeBase64(get(input, 'payload').replace(/ /g, '+'));
246
247
  const payload = json === undefined ? undefined : parseJsonObject(json);
247
248
  if (payload === undefined) {
248
249
  throw fail();
@@ -251,6 +252,10 @@ export class AyaPay {
251
252
  for (const field of PAYLOAD_FIELDS) {
252
253
  const key = field === 'currencyCode' && 'currenyCode' in payload ? 'currenyCode' : field;
253
254
  if (Object.prototype.hasOwnProperty.call(payload, key)) {
255
+ // A nested value is never signed, so a payload carrying one is rejected.
256
+ if (isNested(payload[key])) {
257
+ throw fail();
258
+ }
254
259
  parts.push(scalarString(payload[key]) ?? '');
255
260
  }
256
261
  }
@@ -43,9 +43,10 @@ export declare class Amount {
43
43
  /** Whether the amount is greater than zero. */
44
44
  isPositive(): boolean;
45
45
  /**
46
- * Whether both amounts have the same value, ignoring trailing fractional zeros
47
- * (`1000`, `1000.0` and `1000.00` are equal). `undefined` is never equal, so a gateway amount
48
- * that was not sent never matches.
46
+ * Whether both amounts have the same value. Leading zeros of the whole part and trailing zeros of
47
+ * the fraction are ignored (`1000`, `01000` and `1000.00` are equal). Text must be plain digits
48
+ * with an optional fraction, so `1,000` or ` 1000` is never equal, and neither is `null` or
49
+ * `undefined` (a gateway amount that was not sent never matches).
49
50
  */
50
51
  equals(other: Amount | string | null | undefined): boolean;
51
52
  }
@@ -85,19 +85,28 @@ export class Amount {
85
85
  return !this.isZero();
86
86
  }
87
87
  /**
88
- * Whether both amounts have the same value, ignoring trailing fractional zeros
89
- * (`1000`, `1000.0` and `1000.00` are equal). `undefined` is never equal, so a gateway amount
90
- * that was not sent never matches.
88
+ * Whether both amounts have the same value. Leading zeros of the whole part and trailing zeros of
89
+ * the fraction are ignored (`1000`, `01000` and `1000.00` are equal). Text must be plain digits
90
+ * with an optional fraction, so `1,000` or ` 1000` is never equal, and neither is `null` or
91
+ * `undefined` (a gateway amount that was not sent never matches).
91
92
  */
92
93
  equals(other) {
93
94
  if (other === undefined || other === null) {
94
95
  return false;
95
96
  }
96
- const normalize = (value) => value.includes('.') ? value.replace(/0+$/, '').replace(/\.$/, '') : value;
97
97
  const text = other instanceof Amount ? other.value : other;
98
+ if (typeof text !== 'string' || !PATTERN.test(text)) {
99
+ return false;
100
+ }
98
101
  return normalize(this.value) === normalize(text);
99
102
  }
100
103
  }
104
+ function normalize(value) {
105
+ const [whole = '', fraction = ''] = value.split('.');
106
+ const trimmedWhole = whole.replace(/^0+/, '') || '0';
107
+ const trimmedFraction = fraction.replace(/0+$/, '');
108
+ return trimmedFraction === '' ? trimmedWhole : `${trimmedWhole}.${trimmedFraction}`;
109
+ }
101
110
  function invalid(message) {
102
111
  return new InvalidPaymentDataError({ amount: message });
103
112
  }
@@ -16,8 +16,8 @@ export interface CallbackRequestInit {
16
16
  query?: QueryInput | null | undefined;
17
17
  }
18
18
  /**
19
- * The parts of a Node `http.IncomingMessage` this package reads, so Express, Fastify (`request.raw`)
20
- * and Koa (`ctx.req`) requests work too.
19
+ * The parts of a Node `http.IncomingMessage` this package reads, so Express requests work too (and
20
+ * Koa's `ctx.req` when no body parser consumed it).
21
21
  */
22
22
  export interface NodeRequestLike extends AsyncIterable<unknown> {
23
23
  headers: Readonly<Record<string, string | readonly string[] | undefined>>;
@@ -42,8 +42,10 @@ export interface WebRequestLike {
42
42
  * {@link CallbackRequest.fromNodeRequest} or {@link CallbackRequest.fromWebRequest}.
43
43
  */
44
44
  export declare class CallbackRequest {
45
- /** The raw request body, decoded as UTF-8. */
45
+ /** The request body as text: a string body as given, bytes decoded as UTF-8. */
46
46
  readonly body: string;
47
+ /** The exact bytes received. A string body is encoded as UTF-8. */
48
+ readonly rawBody: Uint8Array;
47
49
  /** The request headers, with lowercase names. Repeated headers are joined with `, `. */
48
50
  readonly headers: Readonly<Record<string, string>>;
49
51
  /** The query string parameters (the first value of each). */
@@ -57,10 +59,11 @@ export declare class CallbackRequest {
57
59
  */
58
60
  static fromJson(payload: unknown, headers?: HeadersInput | null): CallbackRequest;
59
61
  /**
60
- * Reads a Node `http.IncomingMessage` (also Express `req`, Fastify `request.raw`, Koa `ctx.req`).
61
- * Reads the raw body from the stream, or from `rawBody`/`body` when middleware captured it as
62
- * text or bytes. When a body parser already consumed the stream, the parsed `body` is encoded
63
- * again as JSON or a form.
62
+ * Reads a Node `http.IncomingMessage` (also Express `req`, or Koa `ctx.req` without a body
63
+ * parser). Reads the raw body from the stream, or from `rawBody`/`body` when middleware captured
64
+ * it as text or bytes. When a body parser already consumed the stream, the parsed `body` is
65
+ * encoded again as JSON or a form. Fastify parses the body before your handler runs: use
66
+ * {@link CallbackRequest.from} with the raw body there.
64
67
  */
65
68
  static fromNodeRequest(request: NodeRequestLike): Promise<CallbackRequest>;
66
69
  /**
@@ -70,7 +73,10 @@ export declare class CallbackRequest {
70
73
  static fromWebRequest(request: WebRequestLike): Promise<CallbackRequest>;
71
74
  /** The named header, matched case-insensitively, or `undefined`. */
72
75
  header(name: string): string | undefined;
73
- /** The body decoded as JSON or as a urlencoded form. */
76
+ /**
77
+ * The body decoded as JSON or as a urlencoded form. JSON numbers keep their exact text, as strings
78
+ * (`1000.50` stays `"1000.50"`).
79
+ */
74
80
  parsedBody(): Record<string, unknown>;
75
81
  /** The parsed body merged over the query string. */
76
82
  input(): Record<string, unknown>;
@@ -5,14 +5,17 @@ import { parseJsonObject, setKey, toPlainObject } from './json.js';
5
5
  * {@link CallbackRequest.fromNodeRequest} or {@link CallbackRequest.fromWebRequest}.
6
6
  */
7
7
  export class CallbackRequest {
8
- /** The raw request body, decoded as UTF-8. */
8
+ /** The request body as text: a string body as given, bytes decoded as UTF-8. */
9
9
  body;
10
+ /** The exact bytes received. A string body is encoded as UTF-8. */
11
+ rawBody;
10
12
  /** The request headers, with lowercase names. Repeated headers are joined with `, `. */
11
13
  headers;
12
14
  /** The query string parameters (the first value of each). */
13
15
  query;
14
16
  constructor(init = {}) {
15
- this.body = bodyText(init.body);
17
+ this.rawBody = bodyBytes(init.body);
18
+ this.body = typeof init.body === 'string' ? init.body : new TextDecoder().decode(this.rawBody);
16
19
  this.headers = Object.freeze(normalizeHeaders(init.headers));
17
20
  this.query = Object.freeze(normalizeQuery(init.query));
18
21
  }
@@ -31,10 +34,11 @@ export class CallbackRequest {
31
34
  });
32
35
  }
33
36
  /**
34
- * Reads a Node `http.IncomingMessage` (also Express `req`, Fastify `request.raw`, Koa `ctx.req`).
35
- * Reads the raw body from the stream, or from `rawBody`/`body` when middleware captured it as
36
- * text or bytes. When a body parser already consumed the stream, the parsed `body` is encoded
37
- * again as JSON or a form.
37
+ * Reads a Node `http.IncomingMessage` (also Express `req`, or Koa `ctx.req` without a body
38
+ * parser). Reads the raw body from the stream, or from `rawBody`/`body` when middleware captured
39
+ * it as text or bytes. When a body parser already consumed the stream, the parsed `body` is
40
+ * encoded again as JSON or a form. Fastify parses the body before your handler runs: use
41
+ * {@link CallbackRequest.from} with the raw body there.
38
42
  */
39
43
  static async fromNodeRequest(request) {
40
44
  const headers = normalizeHeaders(request.headers);
@@ -67,7 +71,10 @@ export class CallbackRequest {
67
71
  header(name) {
68
72
  return this.headers[name.toLowerCase()];
69
73
  }
70
- /** The body decoded as JSON or as a urlencoded form. */
74
+ /**
75
+ * The body decoded as JSON or as a urlencoded form. JSON numbers keep their exact text, as strings
76
+ * (`1000.50` stays `"1000.50"`).
77
+ */
71
78
  parsedBody() {
72
79
  return toPlainObject(losslessBody(this));
73
80
  }
@@ -206,14 +213,15 @@ export class PaymentStatusResult {
206
213
  return this.status === 'successful';
207
214
  }
208
215
  }
209
- function bodyText(body) {
216
+ function bodyBytes(body) {
210
217
  if (body === undefined || body === null) {
211
- return '';
218
+ return new Uint8Array();
212
219
  }
213
220
  if (typeof body === 'string') {
214
- return body;
221
+ return new TextEncoder().encode(body);
215
222
  }
216
- return new TextDecoder().decode(body);
223
+ // A copy, so later changes to the caller's buffer cannot alter what was verified.
224
+ return body instanceof ArrayBuffer ? new Uint8Array(body.slice(0)) : Uint8Array.from(body);
217
225
  }
218
226
  function rawBody(value) {
219
227
  if (typeof value === 'string' || value instanceof Uint8Array || value instanceof ArrayBuffer) {
@@ -15,5 +15,10 @@ export declare function randomHex(bytes?: number): string;
15
15
  * @internal
16
16
  */
17
17
  export declare function queryEscape(value: string): string;
18
- /** Decodes strict standard base64 (with padding), or `undefined`. @internal */
18
+ /**
19
+ * Decodes standard base64 (`A-Z a-z 0-9 + /`), either correctly padded or without any padding, to
20
+ * UTF-8 text. Partial padding, other characters and bytes that are not UTF-8 return `undefined`.
21
+ *
22
+ * @internal
23
+ */
19
24
  export declare function decodeBase64(value: string): string | undefined;
@@ -32,10 +32,24 @@ export function queryEscape(value) {
32
32
  .replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
33
33
  .replace(/%20/g, '+');
34
34
  }
35
- /** Decodes strict standard base64 (with padding), or `undefined`. @internal */
35
+ const UTF8 = new TextDecoder('utf-8', { fatal: true });
36
+ /**
37
+ * Decodes standard base64 (`A-Z a-z 0-9 + /`), either correctly padded or without any padding, to
38
+ * UTF-8 text. Partial padding, other characters and bytes that are not UTF-8 return `undefined`.
39
+ *
40
+ * @internal
41
+ */
36
42
  export function decodeBase64(value) {
37
- if (value === '' || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
43
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) {
44
+ return undefined;
45
+ }
46
+ if (value.includes('=') ? value.length % 4 !== 0 : value.length % 4 === 1) {
47
+ return undefined;
48
+ }
49
+ try {
50
+ return UTF8.decode(Buffer.from(value, 'base64'));
51
+ }
52
+ catch {
38
53
  return undefined;
39
54
  }
40
- return Buffer.from(value, 'base64').toString('utf8');
41
55
  }
@@ -9,6 +9,13 @@ export declare function envFirst(env: EnvSource, ...keys: string[]): string;
9
9
  * @internal
10
10
  */
11
11
  export declare function envSandbox(env: EnvSource, key: string): boolean;
12
+ /**
13
+ * A `sandbox` setting: booleans as is, text parsed like a `*_SANDBOX` variable (`false`, `0`, `f`,
14
+ * `no` and `off` in any case select production; anything else means sandbox), unset means sandbox.
15
+ *
16
+ * @internal
17
+ */
18
+ export declare function parseSandbox(value: boolean | string | undefined | null): boolean;
12
19
  /** An integer variable, or `undefined` when unset or not an integer. @internal */
13
20
  export declare function envInt(env: EnvSource, key: string): number | undefined;
14
21
  /** The trimmed value, or throws a {@link ConfigurationError} naming `key`. @internal */
@@ -16,8 +16,19 @@ export function envFirst(env, ...keys) {
16
16
  * @internal
17
17
  */
18
18
  export function envSandbox(env, key) {
19
- const value = env[key]?.trim().toLowerCase() ?? '';
20
- return !['false', '0', 'f', 'no', 'off'].includes(value);
19
+ return parseSandbox(env[key] ?? '');
20
+ }
21
+ /**
22
+ * A `sandbox` setting: booleans as is, text parsed like a `*_SANDBOX` variable (`false`, `0`, `f`,
23
+ * `no` and `off` in any case select production; anything else means sandbox), unset means sandbox.
24
+ *
25
+ * @internal
26
+ */
27
+ export function parseSandbox(value) {
28
+ if (typeof value === 'boolean') {
29
+ return value;
30
+ }
31
+ return !['false', '0', 'f', 'no', 'off'].includes((value ?? '').trim().toLowerCase());
21
32
  }
22
33
  /** An integer variable, or `undefined` when unset or not an integer. @internal */
23
34
  export function envInt(env, key) {
@@ -32,8 +32,8 @@ export declare function parseJsonObject(text: string): LosslessObject | undefine
32
32
  /** @internal */
33
33
  export declare function isLosslessObject(value: unknown): value is LosslessObject;
34
34
  /**
35
- * Converts a lossless value to plain JavaScript values (numbers become `number`), for the `raw`
36
- * fields of results.
35
+ * Converts a lossless value to plain JavaScript values for the `raw` fields of results. Numbers
36
+ * become their exact text as a string (`1000.50` stays `"1000.50"`), so no precision is lost.
37
37
  *
38
38
  * @internal
39
39
  */
@@ -46,14 +46,14 @@ export function isLosslessObject(value) {
46
46
  !(value instanceof JsonNumber));
47
47
  }
48
48
  /**
49
- * Converts a lossless value to plain JavaScript values (numbers become `number`), for the `raw`
50
- * fields of results.
49
+ * Converts a lossless value to plain JavaScript values for the `raw` fields of results. Numbers
50
+ * become their exact text as a string (`1000.50` stays `"1000.50"`), so no precision is lost.
51
51
  *
52
52
  * @internal
53
53
  */
54
54
  export function toPlain(value) {
55
55
  if (value instanceof JsonNumber) {
56
- return Number(value.text);
56
+ return value.text;
57
57
  }
58
58
  if (Array.isArray(value)) {
59
59
  return value.map(toPlain);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * A gateway-independent payment status. Every gateway's own status values are mapped onto these.
3
3
  */
4
- export type PaymentStatus = 'successful' | 'pending' | 'failed' | 'cancelled' | 'expired' | 'unknown';
4
+ export type PaymentStatus = 'successful' | 'pending' | 'failed' | 'canceled' | 'expired' | 'unknown';
5
5
  /**
6
6
  * The payment statuses, plus helpers.
7
7
  *
@@ -18,13 +18,13 @@ export declare const PaymentStatus: Readonly<{
18
18
  /** The payment was attempted and failed or was rejected. */
19
19
  Failed: "failed";
20
20
  /** The payment or order was canceled or closed before completing. */
21
- Cancelled: "cancelled";
21
+ Canceled: "canceled";
22
22
  /** The payment window ran out before the customer paid. */
23
23
  Expired: "expired";
24
24
  /** The gateway sent a status this package does not recognize. Inspect `gatewayStatus`. */
25
25
  Unknown: "unknown";
26
26
  /** Every status, in the order above. */
27
- values: readonly ["successful", "pending", "failed", "cancelled", "expired", "unknown"];
27
+ values: readonly ["successful", "pending", "failed", "canceled", "expired", "unknown"];
28
28
  /** Whether the status will not change any more: `false` for `pending` and `unknown`. */
29
29
  isFinal(status: PaymentStatus): boolean;
30
30
  }>;
@@ -14,7 +14,7 @@ export const PaymentStatus = Object.freeze({
14
14
  /** The payment was attempted and failed or was rejected. */
15
15
  Failed: 'failed',
16
16
  /** The payment or order was canceled or closed before completing. */
17
- Cancelled: 'cancelled',
17
+ Canceled: 'canceled',
18
18
  /** The payment window ran out before the customer paid. */
19
19
  Expired: 'expired',
20
20
  /** The gateway sent a status this package does not recognize. Inspect `gatewayStatus`. */
@@ -24,7 +24,7 @@ export const PaymentStatus = Object.freeze({
24
24
  'successful',
25
25
  'pending',
26
26
  'failed',
27
- 'cancelled',
27
+ 'canceled',
28
28
  'expired',
29
29
  'unknown',
30
30
  ]),
@@ -14,3 +14,5 @@ export declare function optional(source: Record<string, unknown>, key: string):
14
14
  export declare function trimmed(source: Record<string, unknown>, key: string): string;
15
15
  /** The value at `key` when it is an object (not an array). @internal */
16
16
  export declare function object(source: Record<string, unknown>, key: string): LosslessObject | undefined;
17
+ /** Whether `value` is an object or array, which a gateway never signs. @internal */
18
+ export declare function isNested(value: unknown): boolean;
@@ -36,3 +36,7 @@ export function object(source, key) {
36
36
  const value = source[key];
37
37
  return isLosslessObject(value) ? value : undefined;
38
38
  }
39
+ /** Whether `value` is an object or array, which a gateway never signs. @internal */
40
+ export function isNested(value) {
41
+ return typeof value === 'object' && value !== null && !(value instanceof JsonNumber);
42
+ }
@@ -10,8 +10,11 @@ export interface CyberSourceConfigOptions {
10
10
  accessKey: string;
11
11
  /** The profile's secret key, which signs the fields. */
12
12
  secretKey: string;
13
- /** Use the test environment (default `true`). */
14
- sandbox?: boolean | undefined;
13
+ /**
14
+ * Use the test environment (default `true`).
15
+ * Text is read like a `*_SANDBOX` variable: `false`, `0`, `f`, `no` or `off` select production.
16
+ */
17
+ sandbox?: boolean | string | undefined;
15
18
  /** Overrides the Secure Acceptance base URL. */
16
19
  baseUrl?: string | undefined;
17
20
  }
@@ -87,7 +90,8 @@ export declare class CyberSource {
87
90
  initiate(data: CyberSourcePaymentData): FormPayment;
88
91
  /**
89
92
  * Verifies CyberSource's result post. The same check works for the browser post to your receipt
90
- * page.
93
+ * page. Only the fields listed in `signed_field_names` are trusted: `decision` and
94
+ * `req_reference_number` must be signed, and unsigned fields are left out of the result.
91
95
  */
92
96
  handleCallback(request: CallbackRequest): PaymentCallback;
93
97
  /** Signs the fields listed in `signed_field_names`; `undefined` when a listed field is missing. */
@@ -1,9 +1,9 @@
1
1
  import {} from '../core/amount.js';
2
2
  import { CallbackRequest, losslessInput, PaymentCallback } from '../core/callback.js';
3
3
  import { hmacSha256Base64, randomHex, safeEqual } from '../core/crypto.js';
4
- import { defaultEnv, envFirst, envSandbox, optionalSetting, requireSetting, trimUrl, } from '../core/env.js';
4
+ import { defaultEnv, envFirst, envSandbox, parseSandbox, optionalSetting, requireSetting, trimUrl, } from '../core/env.js';
5
5
  import { SignatureVerificationError } from '../core/errors.js';
6
- import { toPlainObject } from '../core/json.js';
6
+ import { setKey, toPlainObject } from '../core/json.js';
7
7
  import { FormPayment } from '../core/results.js';
8
8
  import { resolveStatus } from '../core/status.js';
9
9
  import { toAmount, Validator } from '../core/validate.js';
@@ -24,7 +24,7 @@ export class CyberSourceConfig {
24
24
  this.profileId = requireSetting('cyber_source', 'profile_id', options.profileId);
25
25
  this.accessKey = requireSetting('cyber_source', 'access_key', options.accessKey);
26
26
  this.secretKey = requireSetting('cyber_source', 'secret_key', options.secretKey);
27
- this.sandbox = options.sandbox ?? true;
27
+ this.sandbox = parseSandbox(options.sandbox);
28
28
  this.baseUrl = trimUrl(optionalSetting(options.baseUrl) ??
29
29
  (this.sandbox ? CyberSourceConfig.SANDBOX_URL : CyberSourceConfig.PRODUCTION_URL));
30
30
  }
@@ -74,7 +74,7 @@ const STATUSES = {
74
74
  REVIEW: 'pending',
75
75
  DECLINE: 'failed',
76
76
  ERROR: 'failed',
77
- CANCEL: 'cancelled',
77
+ CANCEL: 'canceled',
78
78
  };
79
79
  /**
80
80
  * CyberSource Secure Acceptance hosted checkout for card payments: signs the form and verifies the
@@ -146,31 +146,39 @@ export class CyberSource {
146
146
  }
147
147
  /**
148
148
  * Verifies CyberSource's result post. The same check works for the browser post to your receipt
149
- * page.
149
+ * page. Only the fields listed in `signed_field_names` are trusted: `decision` and
150
+ * `req_reference_number` must be signed, and unsigned fields are left out of the result.
150
151
  */
151
152
  handleCallback(request) {
152
153
  const payload = losslessInput(request);
153
154
  const expected = this.sign(payload);
154
- if (expected === undefined || !safeEqual(expected, get(payload, 'signature'))) {
155
+ const names = signedNames(payload);
156
+ if (expected === undefined ||
157
+ !safeEqual(expected, get(payload, 'signature')) ||
158
+ !names.includes('decision') ||
159
+ !names.includes('req_reference_number')) {
155
160
  throw new SignatureVerificationError('CyberSource callback signature verification failed.', toPlainObject(payload));
156
161
  }
157
- const decision = trimmed(payload, 'decision').toUpperCase();
162
+ // A signature only covers the listed fields, so anything else in the post could have been
163
+ // added by the sender (e.g. a `decision` next to the signed request fields).
164
+ const signed = {};
165
+ for (const name of [...names, 'signature']) {
166
+ setKey(signed, name, payload[name]);
167
+ }
168
+ const decision = trimmed(signed, 'decision').toUpperCase();
158
169
  return new PaymentCallback({
159
- orderId: get(payload, 'req_reference_number'),
170
+ orderId: get(signed, 'req_reference_number'),
160
171
  status: resolveStatus(STATUSES, decision),
161
172
  gatewayStatus: decision,
162
- gatewayReference: optional(payload, 'transaction_id'),
163
- amount: get(payload, 'auth_amount') || optional(payload, 'req_amount'),
164
- raw: toPlainObject(payload),
173
+ gatewayReference: optional(signed, 'transaction_id'),
174
+ amount: get(signed, 'auth_amount') || optional(signed, 'req_amount'),
175
+ raw: toPlainObject(signed),
165
176
  });
166
177
  }
167
178
  /** Signs the fields listed in `signed_field_names`; `undefined` when a listed field is missing. */
168
179
  sign(fields) {
169
180
  const pairs = [];
170
- for (const name of get(fields, 'signed_field_names').split(',')) {
171
- if (name === '') {
172
- continue;
173
- }
181
+ for (const name of signedNames(fields)) {
174
182
  const value = scalarString(fields[name]);
175
183
  if (value === undefined) {
176
184
  return undefined;
@@ -182,3 +190,9 @@ export class CyberSource {
182
190
  : hmacSha256Base64(this.config.secretKey, pairs.join(','));
183
191
  }
184
192
  }
193
+ /** The names listed in `signed_field_names`, in order. */
194
+ function signedNames(fields) {
195
+ return get(fields, 'signed_field_names')
196
+ .split(',')
197
+ .filter((name) => name !== '');
198
+ }
@@ -12,8 +12,12 @@ export interface KbzPayConfigOptions {
12
12
  appKey: string;
13
13
  /** The `merch_code` KBZ issued. */
14
14
  merchantCode: string;
15
- /** Use the UAT endpoints (default `true`). UAT and production issue separate credentials. */
16
- sandbox?: boolean | undefined;
15
+ /**
16
+ * Use the UAT endpoints (default `true`). UAT and production issue separate
17
+ * credentials. Text is read like a `*_SANDBOX` variable: `false`, `0`, `f`, `no` or `off`
18
+ * select production.
19
+ */
20
+ sandbox?: boolean | string | undefined;
17
21
  /** Overrides the API base URL, e.g. to go through a proxy. */
18
22
  apiUrl?: string | undefined;
19
23
  /** Overrides the PWA checkout URL. A trailing `#` or `#/` is normalized to `#/`. */