@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
package/CHANGELOG.md CHANGED
@@ -12,10 +12,22 @@ Initial release. The version number matches the other Laranex Myanmar payments p
12
12
  - Exact `Amount` type (`Amount.kyat()`, `Amount.parse()`) kept as decimal text, so amounts never pass through a float; payment data also takes a whole-number `number` or `bigint` and rejects fractions. Decimals are accepted only where the gateway documents them (KBZ Pay up to 2 places, CyberSource any); every gateway except CyberSource is MMK only.
13
13
  - A config class per gateway with `fromEnv(process.env)`, reading the same `*_SANDBOX`, credential and URL variables as the PHP, Laravel and Go packages; a missing credential throws `ConfigurationError`. `MyanmarPayments` builds every gateway lazily from one config object or the environment.
14
14
  - Result classes: `RedirectPayment`, `FormPayment` (with `toHtml()`), `QrPayment` (with `qrImageDataUri()`) and `AppPayment`; `PaymentCallback` (with a gateway-independent `PaymentStatus` and the `Acknowledgement` each gateway expects, sent with `send(res)` or `toResponse()`) and `PaymentStatusResult`.
15
- - `CallbackRequest.fromNodeRequest()` (node:http, Express, Fastify, Koa), `CallbackRequest.fromWebRequest()` (Fetch API servers such as Next.js, Hono and Bun), `CallbackRequest.from()` and `CallbackRequest.fromJson()`. JSON numbers in callbacks are read without a float conversion, so signatures and amounts keep the gateway's exact text.
15
+ - `CallbackRequest.fromNodeRequest()` (node:http, Express), `CallbackRequest.fromWebRequest()` (Fetch API servers such as Next.js, Hono and Bun), `CallbackRequest.from()` and `CallbackRequest.fromJson()`. JSON numbers in callbacks are read without a float conversion, so signatures and amounts keep the gateway's exact text.
16
16
  - Errors: `PaymentError` and its subclasses `InvalidPaymentDataError` (per-field `errors`), `ApiError` (`gatewayCode`, `gatewayMessage`, `httpStatus`, `raw`, `cause`), `SignatureVerificationError` and `ConfigurationError`.
17
17
  - No runtime dependencies: the global `fetch` (or an injected `fetch` / `HttpClient`, 30 second default timeout, `AbortSignal` per call) and `node:crypto`; an injectable `TokenCache` with an in-memory default for Yoma's access token.
18
+ - CyberSource callbacks trust only the fields listed in `signed_field_names`: `decision` and `req_reference_number` must be signed, and unsigned fields are left out of the result and `raw`.
19
+ - AYA Pay reads a `payload` whose `+` signs became spaces in an unencoded return query string.
18
20
  - Callback, return and cancel URLs only need to be valid absolute http or https URLs; there is no HTTPS-only rule (gateways may still require HTTPS in production).
19
21
  - Wave Money's sandbox is `https://preprodpayments.wavemoney.io:8107`, with checkout at `https://preprodpayments.wavemoney.io/authenticate`.
20
22
  - Agent skill in `skills/node-myanmar-payments` so coding agents use the package correctly; install it with `npx skills add laranex/node-myanmar-payments`.
21
23
  - Requires Node.js 20 or higher.
24
+
25
+ ### Changed since the pre-releases
26
+ - `PaymentStatus.Cancelled` is renamed to `PaymentStatus.Canceled` and its value from `'cancelled'` to `'canceled'` (American English), with no alias. Code or stored statuses from the `v4.0.0-dev` pre-releases need the new name; gateway status literals such as Wave Money's `PAYMENT_REQUEST_CANCELLED` are unchanged.
27
+ - Callback values that are JSON numbers are kept as their exact text, as strings, in `raw`, `parsedBody()`, `input()`, `queryInput()` and the `raw` of errors (`1000.50` is `'1000.50'`, not `1000.5`), like the PHP SDK.
28
+ - A nested value (object or array) in a signed or hashed callback field now fails verification: KBZ Pay (any callback field), Wave Money (the hashed fields), AYA Pay (the signed payload fields) and Yoma MMQR (`status`). Before, KBZ Pay skipped it, Wave Money hashed it as `null` and AYA Pay signed it as empty.
29
+ - AYA Pay accepts a base64 `payload` without padding as well as with full padding; partial padding, the URL-safe alphabet, line breaks and payloads that are not UTF-8 are rejected.
30
+ - Yoma MMQR caches its access token under `myanmar-payments.yoma-mmqr.token.<sha256 of base URL and client id>` (was `node-myanmar-payments.…`), the same key as the PHP, Go and Python SDKs, and reads `expires_in` from its leading digits.
31
+ - `Amount.equals()` compares by value, ignoring leading zeros too (`'01000'` equals `Amount.kyat(1000)`); text that is not plain digits with an optional fraction is never equal.
32
+ - `CallbackRequest.rawBody` holds the exact bytes received (a string body is encoded as UTF-8), next to `body` as text, like Python's `raw_body` and Go's `Body`.
33
+ - A config's `sandbox` option also takes a string, read like the `*_SANDBOX` variable (`false`, `0`, `f`, `no` or `off` select production).
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Node Myanmar Payments
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@laranex/myanmar-payments.svg?style=flat-square)](https://www.npmjs.com/package/@laranex/myanmar-payments)
4
- [![Tests](https://img.shields.io/github/actions/workflow/status/laranex/node-myanmar-payments/tests.yml?label=tests&style=flat-square)](https://github.com/laranex/node-myanmar-payments/actions/workflows/tests.yml)
4
+ [![Tests](https://github.com/laranex/node-myanmar-payments/actions/workflows/tests.yml/badge.svg)](https://github.com/laranex/node-myanmar-payments/actions/workflows/tests.yml)
5
5
  [![License](https://img.shields.io/npm/l/@laranex/myanmar-payments.svg?style=flat-square)](LICENSE.md)
6
6
 
7
7
  Node.js SDK for Myanmar payment gateways: KBZ Pay, Wave Money, AYA Pay, Yoma MMQR and CyberSource. Typed TypeScript API, ESM and CommonJS, no runtime dependencies. Built for humans and AI agents.
@@ -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
  }
@@ -26,7 +26,7 @@ class AyaPayConfig {
26
26
  constructor(options) {
27
27
  this.appKey = (0, env_js_1.requireSetting)('aya_pay', 'app_key', options.appKey);
28
28
  this.appSecret = (0, env_js_1.requireSetting)('aya_pay', 'app_secret', options.appSecret);
29
- this.sandbox = options.sandbox ?? true;
29
+ this.sandbox = (0, env_js_1.parseSandbox)(options.sandbox);
30
30
  this.baseUrl = (0, env_js_1.trimUrl)((0, env_js_1.optionalSetting)(options.baseUrl) ??
31
31
  (this.sandbox ? AyaPayConfig.SANDBOX_URL : AyaPayConfig.PRODUCTION_URL));
32
32
  }
@@ -246,7 +246,8 @@ class AyaPay {
246
246
  /** Decodes a `payload` + `checkSum` pair and returns the payload if the checksum matches. */
247
247
  verifiedPayload(input, context) {
248
248
  const fail = () => new errors_js_1.SignatureVerificationError(`AYA Pay ${context} checksum verification failed.`, (0, json_js_1.toPlainObject)(input));
249
- const json = (0, crypto_js_1.decodeBase64)((0, values_js_1.get)(input, 'payload'));
249
+ // Base64 never contains spaces: a space is a `+` that an unencoded query string turned into one.
250
+ const json = (0, crypto_js_1.decodeBase64)((0, values_js_1.get)(input, 'payload').replace(/ /g, '+'));
250
251
  const payload = json === undefined ? undefined : (0, json_js_1.parseJsonObject)(json);
251
252
  if (payload === undefined) {
252
253
  throw fail();
@@ -255,6 +256,10 @@ class AyaPay {
255
256
  for (const field of PAYLOAD_FIELDS) {
256
257
  const key = field === 'currencyCode' && 'currenyCode' in payload ? 'currenyCode' : field;
257
258
  if (Object.prototype.hasOwnProperty.call(payload, key)) {
259
+ // A nested value is never signed, so a payload carrying one is rejected.
260
+ if ((0, values_js_1.isNested)(payload[key])) {
261
+ throw fail();
262
+ }
258
263
  parts.push((0, values_js_1.scalarString)(payload[key]) ?? '');
259
264
  }
260
265
  }
@@ -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
  }
@@ -88,20 +88,29 @@ class Amount {
88
88
  return !this.isZero();
89
89
  }
90
90
  /**
91
- * Whether both amounts have the same value, ignoring trailing fractional zeros
92
- * (`1000`, `1000.0` and `1000.00` are equal). `undefined` is never equal, so a gateway amount
93
- * that was not sent never matches.
91
+ * Whether both amounts have the same value. Leading zeros of the whole part and trailing zeros of
92
+ * the fraction are ignored (`1000`, `01000` and `1000.00` are equal). Text must be plain digits
93
+ * with an optional fraction, so `1,000` or ` 1000` is never equal, and neither is `null` or
94
+ * `undefined` (a gateway amount that was not sent never matches).
94
95
  */
95
96
  equals(other) {
96
97
  if (other === undefined || other === null) {
97
98
  return false;
98
99
  }
99
- const normalize = (value) => value.includes('.') ? value.replace(/0+$/, '').replace(/\.$/, '') : value;
100
100
  const text = other instanceof Amount ? other.value : other;
101
+ if (typeof text !== 'string' || !PATTERN.test(text)) {
102
+ return false;
103
+ }
101
104
  return normalize(this.value) === normalize(text);
102
105
  }
103
106
  }
104
107
  exports.Amount = Amount;
108
+ function normalize(value) {
109
+ const [whole = '', fraction = ''] = value.split('.');
110
+ const trimmedWhole = whole.replace(/^0+/, '') || '0';
111
+ const trimmedFraction = fraction.replace(/0+$/, '');
112
+ return trimmedFraction === '' ? trimmedWhole : `${trimmedWhole}.${trimmedFraction}`;
113
+ }
105
114
  function invalid(message) {
106
115
  return new errors_js_1.InvalidPaymentDataError({ amount: message });
107
116
  }
@@ -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>;
@@ -11,14 +11,17 @@ const json_js_1 = require("./json.js");
11
11
  * {@link CallbackRequest.fromNodeRequest} or {@link CallbackRequest.fromWebRequest}.
12
12
  */
13
13
  class CallbackRequest {
14
- /** The raw request body, decoded as UTF-8. */
14
+ /** The request body as text: a string body as given, bytes decoded as UTF-8. */
15
15
  body;
16
+ /** The exact bytes received. A string body is encoded as UTF-8. */
17
+ rawBody;
16
18
  /** The request headers, with lowercase names. Repeated headers are joined with `, `. */
17
19
  headers;
18
20
  /** The query string parameters (the first value of each). */
19
21
  query;
20
22
  constructor(init = {}) {
21
- this.body = bodyText(init.body);
23
+ this.rawBody = bodyBytes(init.body);
24
+ this.body = typeof init.body === 'string' ? init.body : new TextDecoder().decode(this.rawBody);
22
25
  this.headers = Object.freeze(normalizeHeaders(init.headers));
23
26
  this.query = Object.freeze(normalizeQuery(init.query));
24
27
  }
@@ -37,10 +40,11 @@ class CallbackRequest {
37
40
  });
38
41
  }
39
42
  /**
40
- * Reads a Node `http.IncomingMessage` (also Express `req`, Fastify `request.raw`, Koa `ctx.req`).
41
- * Reads the raw body from the stream, or from `rawBody`/`body` when middleware captured it as
42
- * text or bytes. When a body parser already consumed the stream, the parsed `body` is encoded
43
- * again as JSON or a form.
43
+ * Reads a Node `http.IncomingMessage` (also Express `req`, or Koa `ctx.req` without a body
44
+ * parser). Reads the raw body from the stream, or from `rawBody`/`body` when middleware captured
45
+ * it as text or bytes. When a body parser already consumed the stream, the parsed `body` is
46
+ * encoded again as JSON or a form. Fastify parses the body before your handler runs: use
47
+ * {@link CallbackRequest.from} with the raw body there.
44
48
  */
45
49
  static async fromNodeRequest(request) {
46
50
  const headers = normalizeHeaders(request.headers);
@@ -73,7 +77,10 @@ class CallbackRequest {
73
77
  header(name) {
74
78
  return this.headers[name.toLowerCase()];
75
79
  }
76
- /** The body decoded as JSON or as a urlencoded form. */
80
+ /**
81
+ * The body decoded as JSON or as a urlencoded form. JSON numbers keep their exact text, as strings
82
+ * (`1000.50` stays `"1000.50"`).
83
+ */
77
84
  parsedBody() {
78
85
  return (0, json_js_1.toPlainObject)(losslessBody(this));
79
86
  }
@@ -216,14 +223,15 @@ class PaymentStatusResult {
216
223
  }
217
224
  }
218
225
  exports.PaymentStatusResult = PaymentStatusResult;
219
- function bodyText(body) {
226
+ function bodyBytes(body) {
220
227
  if (body === undefined || body === null) {
221
- return '';
228
+ return new Uint8Array();
222
229
  }
223
230
  if (typeof body === 'string') {
224
- return body;
231
+ return new TextEncoder().encode(body);
225
232
  }
226
- return new TextDecoder().decode(body);
233
+ // A copy, so later changes to the caller's buffer cannot alter what was verified.
234
+ return body instanceof ArrayBuffer ? new Uint8Array(body.slice(0)) : Uint8Array.from(body);
227
235
  }
228
236
  function rawBody(value) {
229
237
  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;
@@ -41,10 +41,24 @@ function queryEscape(value) {
41
41
  .replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
42
42
  .replace(/%20/g, '+');
43
43
  }
44
- /** Decodes strict standard base64 (with padding), or `undefined`. @internal */
44
+ const UTF8 = new TextDecoder('utf-8', { fatal: true });
45
+ /**
46
+ * Decodes standard base64 (`A-Z a-z 0-9 + /`), either correctly padded or without any padding, to
47
+ * UTF-8 text. Partial padding, other characters and bytes that are not UTF-8 return `undefined`.
48
+ *
49
+ * @internal
50
+ */
45
51
  function decodeBase64(value) {
46
- if (value === '' || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
52
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) {
53
+ return undefined;
54
+ }
55
+ if (value.includes('=') ? value.length % 4 !== 0 : value.length % 4 === 1) {
56
+ return undefined;
57
+ }
58
+ try {
59
+ return UTF8.decode(Buffer.from(value, 'base64'));
60
+ }
61
+ catch {
47
62
  return undefined;
48
63
  }
49
- return Buffer.from(value, 'base64').toString('utf8');
50
64
  }
@@ -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 */
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.envFirst = envFirst;
4
4
  exports.envSandbox = envSandbox;
5
+ exports.parseSandbox = parseSandbox;
5
6
  exports.envInt = envInt;
6
7
  exports.requireSetting = requireSetting;
7
8
  exports.optionalSetting = optionalSetting;
@@ -25,8 +26,19 @@ function envFirst(env, ...keys) {
25
26
  * @internal
26
27
  */
27
28
  function envSandbox(env, key) {
28
- const value = env[key]?.trim().toLowerCase() ?? '';
29
- return !['false', '0', 'f', 'no', 'off'].includes(value);
29
+ return parseSandbox(env[key] ?? '');
30
+ }
31
+ /**
32
+ * A `sandbox` setting: booleans as is, text parsed like a `*_SANDBOX` variable (`false`, `0`, `f`,
33
+ * `no` and `off` in any case select production; anything else means sandbox), unset means sandbox.
34
+ *
35
+ * @internal
36
+ */
37
+ function parseSandbox(value) {
38
+ if (typeof value === 'boolean') {
39
+ return value;
40
+ }
41
+ return !['false', '0', 'f', 'no', 'off'].includes((value ?? '').trim().toLowerCase());
30
42
  }
31
43
  /** An integer variable, or `undefined` when unset or not an integer. @internal */
32
44
  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
  */
@@ -56,14 +56,14 @@ function isLosslessObject(value) {
56
56
  !(value instanceof JsonNumber));
57
57
  }
58
58
  /**
59
- * Converts a lossless value to plain JavaScript values (numbers become `number`), for the `raw`
60
- * fields of results.
59
+ * Converts a lossless value to plain JavaScript values for the `raw` fields of results. Numbers
60
+ * become their exact text as a string (`1000.50` stays `"1000.50"`), so no precision is lost.
61
61
  *
62
62
  * @internal
63
63
  */
64
64
  function toPlain(value) {
65
65
  if (value instanceof JsonNumber) {
66
- return Number(value.text);
66
+ return value.text;
67
67
  }
68
68
  if (Array.isArray(value)) {
69
69
  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
  }>;
@@ -18,7 +18,7 @@ exports.PaymentStatus = Object.freeze({
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`. */
@@ -28,7 +28,7 @@ exports.PaymentStatus = Object.freeze({
28
28
  'successful',
29
29
  'pending',
30
30
  'failed',
31
- 'cancelled',
31
+ 'canceled',
32
32
  'expired',
33
33
  'unknown',
34
34
  ]),
@@ -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;
@@ -5,6 +5,7 @@ exports.get = get;
5
5
  exports.optional = optional;
6
6
  exports.trimmed = trimmed;
7
7
  exports.object = object;
8
+ exports.isNested = isNested;
8
9
  const json_js_1 = require("./json.js");
9
10
  /**
10
11
  * Returns a scalar as the text a gateway signs: strings as is, numbers exactly as sent, booleans as
@@ -43,3 +44,7 @@ function object(source, key) {
43
44
  const value = source[key];
44
45
  return (0, json_js_1.isLosslessObject)(value) ? value : undefined;
45
46
  }
47
+ /** Whether `value` is an object or array, which a gateway never signs. @internal */
48
+ function isNested(value) {
49
+ return typeof value === 'object' && value !== null && !(value instanceof json_js_1.JsonNumber);
50
+ }
@@ -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. */
@@ -26,7 +26,7 @@ class CyberSourceConfig {
26
26
  this.profileId = (0, env_js_1.requireSetting)('cyber_source', 'profile_id', options.profileId);
27
27
  this.accessKey = (0, env_js_1.requireSetting)('cyber_source', 'access_key', options.accessKey);
28
28
  this.secretKey = (0, env_js_1.requireSetting)('cyber_source', 'secret_key', options.secretKey);
29
- this.sandbox = options.sandbox ?? true;
29
+ this.sandbox = (0, env_js_1.parseSandbox)(options.sandbox);
30
30
  this.baseUrl = (0, env_js_1.trimUrl)((0, env_js_1.optionalSetting)(options.baseUrl) ??
31
31
  (this.sandbox ? CyberSourceConfig.SANDBOX_URL : CyberSourceConfig.PRODUCTION_URL));
32
32
  }
@@ -77,7 +77,7 @@ const STATUSES = {
77
77
  REVIEW: 'pending',
78
78
  DECLINE: 'failed',
79
79
  ERROR: 'failed',
80
- CANCEL: 'cancelled',
80
+ CANCEL: 'canceled',
81
81
  };
82
82
  /**
83
83
  * CyberSource Secure Acceptance hosted checkout for card payments: signs the form and verifies the
@@ -149,31 +149,39 @@ class CyberSource {
149
149
  }
150
150
  /**
151
151
  * Verifies CyberSource's result post. The same check works for the browser post to your receipt
152
- * page.
152
+ * page. Only the fields listed in `signed_field_names` are trusted: `decision` and
153
+ * `req_reference_number` must be signed, and unsigned fields are left out of the result.
153
154
  */
154
155
  handleCallback(request) {
155
156
  const payload = (0, callback_js_1.losslessInput)(request);
156
157
  const expected = this.sign(payload);
157
- if (expected === undefined || !(0, crypto_js_1.safeEqual)(expected, (0, values_js_1.get)(payload, 'signature'))) {
158
+ const names = signedNames(payload);
159
+ if (expected === undefined ||
160
+ !(0, crypto_js_1.safeEqual)(expected, (0, values_js_1.get)(payload, 'signature')) ||
161
+ !names.includes('decision') ||
162
+ !names.includes('req_reference_number')) {
158
163
  throw new errors_js_1.SignatureVerificationError('CyberSource callback signature verification failed.', (0, json_js_1.toPlainObject)(payload));
159
164
  }
160
- const decision = (0, values_js_1.trimmed)(payload, 'decision').toUpperCase();
165
+ // A signature only covers the listed fields, so anything else in the post could have been
166
+ // added by the sender (e.g. a `decision` next to the signed request fields).
167
+ const signed = {};
168
+ for (const name of [...names, 'signature']) {
169
+ (0, json_js_1.setKey)(signed, name, payload[name]);
170
+ }
171
+ const decision = (0, values_js_1.trimmed)(signed, 'decision').toUpperCase();
161
172
  return new callback_js_1.PaymentCallback({
162
- orderId: (0, values_js_1.get)(payload, 'req_reference_number'),
173
+ orderId: (0, values_js_1.get)(signed, 'req_reference_number'),
163
174
  status: (0, status_js_1.resolveStatus)(STATUSES, decision),
164
175
  gatewayStatus: decision,
165
- gatewayReference: (0, values_js_1.optional)(payload, 'transaction_id'),
166
- amount: (0, values_js_1.get)(payload, 'auth_amount') || (0, values_js_1.optional)(payload, 'req_amount'),
167
- raw: (0, json_js_1.toPlainObject)(payload),
176
+ gatewayReference: (0, values_js_1.optional)(signed, 'transaction_id'),
177
+ amount: (0, values_js_1.get)(signed, 'auth_amount') || (0, values_js_1.optional)(signed, 'req_amount'),
178
+ raw: (0, json_js_1.toPlainObject)(signed),
168
179
  });
169
180
  }
170
181
  /** Signs the fields listed in `signed_field_names`; `undefined` when a listed field is missing. */
171
182
  sign(fields) {
172
183
  const pairs = [];
173
- for (const name of (0, values_js_1.get)(fields, 'signed_field_names').split(',')) {
174
- if (name === '') {
175
- continue;
176
- }
184
+ for (const name of signedNames(fields)) {
177
185
  const value = (0, values_js_1.scalarString)(fields[name]);
178
186
  if (value === undefined) {
179
187
  return undefined;
@@ -186,3 +194,9 @@ class CyberSource {
186
194
  }
187
195
  }
188
196
  exports.CyberSource = CyberSource;
197
+ /** The names listed in `signed_field_names`, in order. */
198
+ function signedNames(fields) {
199
+ return (0, values_js_1.get)(fields, 'signed_field_names')
200
+ .split(',')
201
+ .filter((name) => name !== '');
202
+ }
@@ -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 `#/`. */
@@ -32,7 +32,7 @@ class KbzPayConfig {
32
32
  this.appId = (0, env_js_1.requireSetting)('kbz_pay', 'app_id', options.appId);
33
33
  this.appKey = (0, env_js_1.requireSetting)('kbz_pay', 'app_key', options.appKey);
34
34
  this.merchantCode = (0, env_js_1.requireSetting)('kbz_pay', 'merchant_code', options.merchantCode);
35
- this.sandbox = options.sandbox ?? true;
35
+ this.sandbox = (0, env_js_1.parseSandbox)(options.sandbox);
36
36
  this.apiUrl = (0, env_js_1.trimUrl)((0, env_js_1.optionalSetting)(options.apiUrl) ??
37
37
  (this.sandbox ? KbzPayConfig.SANDBOX_API_URL : KbzPayConfig.PRODUCTION_API_URL));
38
38
  this.pwaUrl = `${(0, env_js_1.trimUrl)((0, env_js_1.optionalSetting)(options.pwaUrl) ??
@@ -60,7 +60,7 @@ const STATUSES = {
60
60
  WAIT_PAY: 'pending',
61
61
  PAYING: 'pending',
62
62
  PAY_FAILED: 'failed',
63
- ORDER_CLOSED: 'cancelled',
63
+ ORDER_CLOSED: 'canceled',
64
64
  ORDER_EXPIRED: 'expired',
65
65
  };
66
66
  /**
@@ -10,6 +10,9 @@ export declare class KbzPaySigner {
10
10
  signString(fields: Readonly<Record<string, unknown>>): string;
11
11
  /** The uppercase SHA-256 signature of `fields`. */
12
12
  sign(fields: Readonly<Record<string, unknown>>): string;
13
- /** Whether `fields.sign` matches, compared in constant time. */
13
+ /**
14
+ * Whether `fields.sign` matches, compared in constant time. Nested values are never signed, so
15
+ * fields holding an object or array fail rather than being partly trusted.
16
+ */
14
17
  verify(fields: Readonly<Record<string, unknown>>): boolean;
15
18
  }
@@ -27,10 +27,15 @@ class KbzPaySigner {
27
27
  sign(fields) {
28
28
  return (0, crypto_js_1.sha256Hex)(`${this.signString(fields)}&key=${this.appKey}`).toUpperCase();
29
29
  }
30
- /** Whether `fields.sign` matches, compared in constant time. */
30
+ /**
31
+ * Whether `fields.sign` matches, compared in constant time. Nested values are never signed, so
32
+ * fields holding an object or array fail rather than being partly trusted.
33
+ */
31
34
  verify(fields) {
32
35
  const sign = fields.sign;
33
- return typeof sign === 'string' && (0, crypto_js_1.safeEqual)(this.sign(fields), sign.toUpperCase());
36
+ return (typeof sign === 'string' &&
37
+ !Object.values(fields).some(values_js_1.isNested) &&
38
+ (0, crypto_js_1.safeEqual)(this.sign(fields), sign.toUpperCase()));
34
39
  }
35
40
  }
36
41
  exports.KbzPaySigner = KbzPaySigner;
@@ -13,8 +13,11 @@ export interface WaveMoneyConfigOptions {
13
13
  merchantName: string;
14
14
  /** How long the customer has to pay, in seconds (default 300). */
15
15
  timeToLiveSeconds?: number | undefined;
16
- /** Use the test environment (default `true`). */
17
- sandbox?: boolean | undefined;
16
+ /**
17
+ * Use the test environment (default `true`).
18
+ * Text is read like a `*_SANDBOX` variable: `false`, `0`, `f`, `no` or `off` select production.
19
+ */
20
+ sandbox?: boolean | string | undefined;
18
21
  /** Overrides the API base URL. */
19
22
  baseUrl?: string | undefined;
20
23
  /** Overrides the host the customer is redirected to. Wave serves it without the API port. */
@@ -104,7 +107,7 @@ export declare class WaveMoney {
104
107
  /**
105
108
  * Verifies Wave's callback. Only `PAYMENT_CONFIRMED` means the customer paid. `orderId` falls
106
109
  * back to `merchantReferenceId` when it is missing, null or empty, because Wave marks `orderId` as
107
- * optional.
110
+ * optional. A hashed field holding an object or array fails verification.
108
111
  */
109
112
  handleCallback(request: CallbackRequest): PaymentCallback;
110
113
  private hash;