@vaam-apps/vpay-sdk 0.9.0 → 0.9.1

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/README.md CHANGED
@@ -591,6 +591,22 @@ VPAY_AUDIENCE=https://api.vpay.example/v1/oauth/token \
591
591
 
592
592
  ## Status
593
593
 
594
+ **Behaviour change, 2026-10-21: an empty or all-dots `id` is refused before
595
+ any request.** Every one of the twenty-one methods that put an `id` in the
596
+ path now rejects with a `TypeError` for `""`, `.`, `..` or any longer run of
597
+ dots, without minting a token or sending anything. No signature changed —
598
+ each already returned a `Promise` — but the outcome for those inputs did.
599
+ They used to be sent, and `fetch`'s URL parser resolved them before the
600
+ request left, so `customers.del("..")` was a `DELETE /v1/` and
601
+ `paymentIntents.retrieve("")` a `GET /v1/payment_intents/`; escaping cannot
602
+ help, because the parser reads `%2E%2E` as `..` too. What vpay answered for
603
+ each is on
604
+ [the dated verification page](../../docs/status/verification/2026-10-21-sdk-dot-segment-ids.md).
605
+ A caller catching `VpayApiError` for those inputs now gets a `TypeError`,
606
+ and so does one narrowing on `VpayError`: like the amount refusal, it is not
607
+ one (a dated gap in [docs/sdks/parity.md](../../docs/sdks/parity.md)).
608
+ `sdks/rust` refuses the same ids with `Error::InvalidParams`.
609
+
594
610
  **Corrected 2026-09-03.** This section used to say "what it does **not**
595
611
  have is a single `/v1` resource route" — true when it was written on
596
612
  2026-09-02, and false since Step 2 landed the next day.
@@ -673,6 +689,12 @@ none of them touches the network:
673
689
  `util.inspect` while `JSON.stringify` stays faithful.
674
690
  - The form encoder's nested-object/array/boolean/percent-encoding rules, and
675
691
  that a non-integer `amount` throws `TypeError` before any request is sent.
692
+ - That an empty or all-dots `id` throws `TypeError` on all twenty-one
693
+ id-taking methods before any request, the token request included, with one
694
+ fixed message that never quotes it; that an id with dots in it that is not
695
+ only dots (`a..b`, `.a`, `%2E%2E`) is still sent as exactly one segment;
696
+ and, as the premise, that WHATWG `URL` resolves `..`, `%2E%2E` and `.%2e`
697
+ alike.
676
698
  - Error mapping for a Stripe-shaped `400`, an unexpected `502` HTML body, a
677
699
  connection failure, and a token-endpoint `401` (which is never retried).
678
700
  - Webhook verification: valid/invalid signatures, timestamp tolerance,
@@ -1,3 +1,4 @@
1
+ import { pathSegment } from "../validate.js";
1
2
  const INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
2
3
  /**
3
4
  * The `url` of a hosted session with its fragment redacted.
@@ -89,7 +90,7 @@ export class CheckoutSessionsResource {
89
90
  }
90
91
  /** `GET /v1/checkout/sessions/{id}`. Answers the session with `client_secret`. */
91
92
  async retrieve(id) {
92
- return withRedactedInspect(await this.#http.request("GET", `/checkout/sessions/${encodeURIComponent(id)}`));
93
+ return withRedactedInspect(await this.#http.request("GET", `/checkout/sessions/${pathSegment(id)}`));
93
94
  }
94
95
  /** `GET /v1/checkout/sessions`. List items never carry `client_secret`. */
95
96
  async list(params) {
@@ -106,7 +107,7 @@ export class CheckoutSessionsResource {
106
107
  * whose intent already has a live charge is refused `409`.
107
108
  */
108
109
  async expire(id, options) {
109
- return withRedactedInspect(await this.#http.request("POST", `/checkout/sessions/${encodeURIComponent(id)}/expire`, {}, options));
110
+ return withRedactedInspect(await this.#http.request("POST", `/checkout/sessions/${pathSegment(id)}/expire`, {}, options));
110
111
  }
111
112
  }
112
113
  /**
@@ -1,3 +1,4 @@
1
+ import { pathSegment } from "../validate.js";
1
2
  /**
2
3
  * One patch field, in the wire's own three-state encoding.
3
4
  *
@@ -79,7 +80,7 @@ export class CustomersResource {
79
80
  }
80
81
  /** `GET /v1/customers/{id}`. */
81
82
  async retrieve(id) {
82
- return this.#http.request("GET", `/customers/${encodeURIComponent(id)}`);
83
+ return this.#http.request("GET", `/customers/${pathSegment(id)}`);
83
84
  }
84
85
  /**
85
86
  * `POST /v1/customers/{id}` — the update.
@@ -116,7 +117,7 @@ export class CustomersResource {
116
117
  if (params.metadata !== undefined) {
117
118
  body["metadata"] = params.metadata;
118
119
  }
119
- return this.#http.request("POST", `/customers/${encodeURIComponent(id)}`, body, options);
120
+ return this.#http.request("POST", `/customers/${pathSegment(id)}`, body, options);
120
121
  }
121
122
  /** `GET /v1/customers`. */
122
123
  async list(params) {
@@ -152,6 +153,6 @@ export class CustomersResource {
152
153
  * than a `404`.
153
154
  */
154
155
  async del(id, options) {
155
- return this.#http.request("DELETE", `/customers/${encodeURIComponent(id)}`, undefined, options);
156
+ return this.#http.request("DELETE", `/customers/${pathSegment(id)}`, undefined, options);
156
157
  }
157
158
  }
@@ -1,4 +1,4 @@
1
- import { assertIntegerAmount } from "../validate.js";
1
+ import { assertIntegerAmount, pathSegment } from "../validate.js";
2
2
  /**
3
3
  * One patch field, in the wire's own three-state encoding.
4
4
  *
@@ -61,7 +61,7 @@ export class InvoicesResource {
61
61
  * never existed.
62
62
  */
63
63
  async retrieve(id) {
64
- return this.#http.request("GET", `/invoices/${encodeURIComponent(id)}`);
64
+ return this.#http.request("GET", `/invoices/${pathSegment(id)}`);
65
65
  }
66
66
  /**
67
67
  * `POST /v1/invoices/{id}` — patches a **draft**.
@@ -88,7 +88,7 @@ export class InvoicesResource {
88
88
  if (params.metadata !== undefined) {
89
89
  body["metadata"] = params.metadata;
90
90
  }
91
- return this.#http.request("POST", `/invoices/${encodeURIComponent(id)}`, body, options);
91
+ return this.#http.request("POST", `/invoices/${pathSegment(id)}`, body, options);
92
92
  }
93
93
  /** `GET /v1/invoices` — newest first. */
94
94
  async list(params) {
@@ -107,7 +107,7 @@ export class InvoicesResource {
107
107
  * delete answer the original `{deleted: true}` rather than a `404`.
108
108
  */
109
109
  async del(id, options) {
110
- return this.#http.request("DELETE", `/invoices/${encodeURIComponent(id)}`, undefined, options);
110
+ return this.#http.request("DELETE", `/invoices/${pathSegment(id)}`, undefined, options);
111
111
  }
112
112
  /**
113
113
  * `POST /v1/invoices/{id}/finalize` — issues the document. **Draft only.**
@@ -216,7 +216,7 @@ export class InvoicesResource {
216
216
  },
217
217
  }
218
218
  : { success_url: params.success_url, cancel_url: params.cancel_url };
219
- return this.#http.request("POST", `/invoices/${encodeURIComponent(id)}/pay`, body, options);
219
+ return this.#http.request("POST", `/invoices/${pathSegment(id)}/pay`, body, options);
220
220
  }
221
221
  /**
222
222
  * The three transitions that take no parameters, which are one shape:
@@ -228,7 +228,7 @@ export class InvoicesResource {
228
228
  * that is easy to get wrong in only one of them.
229
229
  */
230
230
  async #transition(id, name, options) {
231
- return this.#http.request("POST", `/invoices/${encodeURIComponent(id)}/${name}`, {}, options);
231
+ return this.#http.request("POST", `/invoices/${pathSegment(id)}/${name}`, {}, options);
232
232
  }
233
233
  }
234
234
  /**
@@ -271,7 +271,7 @@ export class InvoiceItemsResource {
271
271
  }
272
272
  /** `GET /v1/invoice_items/{id}` — readable whatever the parent's status. */
273
273
  async retrieve(id) {
274
- return this.#http.request("GET", `/invoice_items/${encodeURIComponent(id)}`);
274
+ return this.#http.request("GET", `/invoice_items/${pathSegment(id)}`);
275
275
  }
276
276
  /**
277
277
  * `POST /v1/invoice_items/{id}` — **draft parent only**, with `amount`
@@ -294,13 +294,13 @@ export class InvoiceItemsResource {
294
294
  if (params.unit_amount !== undefined) {
295
295
  body["unit_amount"] = params.unit_amount;
296
296
  }
297
- return this.#http.request("POST", `/invoice_items/${encodeURIComponent(id)}`, body, options);
297
+ return this.#http.request("POST", `/invoice_items/${pathSegment(id)}`, body, options);
298
298
  }
299
299
  /**
300
300
  * `DELETE /v1/invoice_items/{id}` — **draft parent only**, and the invoice
301
301
  * re-totals.
302
302
  */
303
303
  async del(id, options) {
304
- return this.#http.request("DELETE", `/invoice_items/${encodeURIComponent(id)}`, undefined, options);
304
+ return this.#http.request("DELETE", `/invoice_items/${pathSegment(id)}`, undefined, options);
305
305
  }
306
306
  }
@@ -1,4 +1,4 @@
1
- import { assertIntegerAmount } from "../validate.js";
1
+ import { assertIntegerAmount, pathSegment } from "../validate.js";
2
2
  /** `/v1/payment_intents` — docs/flows/merchant-auth.md, "Resources". */
3
3
  export class PaymentIntentsResource {
4
4
  #http;
@@ -24,7 +24,7 @@ export class PaymentIntentsResource {
24
24
  return this.#http.request("POST", "/payment_intents", body, options);
25
25
  }
26
26
  async retrieve(id) {
27
- return this.#http.request("GET", `/payment_intents/${encodeURIComponent(id)}`);
27
+ return this.#http.request("GET", `/payment_intents/${pathSegment(id)}`);
28
28
  }
29
29
  async confirm(id, params, options) {
30
30
  const body = {
@@ -33,10 +33,10 @@ export class PaymentIntentsResource {
33
33
  if ("return_url" in params) {
34
34
  body["return_url"] = params.return_url;
35
35
  }
36
- return this.#http.request("POST", `/payment_intents/${encodeURIComponent(id)}/confirm`, body, options);
36
+ return this.#http.request("POST", `/payment_intents/${pathSegment(id)}/confirm`, body, options);
37
37
  }
38
38
  async cancel(id, options) {
39
- return this.#http.request("POST", `/payment_intents/${encodeURIComponent(id)}/cancel`, {}, options);
39
+ return this.#http.request("POST", `/payment_intents/${pathSegment(id)}/cancel`, {}, options);
40
40
  }
41
41
  /** `GET /v1/payment_intents`, newest first; `customer` narrows it. */
42
42
  async list(params) {
@@ -1,4 +1,4 @@
1
- import { assertIntegerAmount } from "../validate.js";
1
+ import { assertIntegerAmount, pathSegment } from "../validate.js";
2
2
  /**
3
3
  * The rail-agnostic envelope, built around the rail-specific interior.
4
4
  *
@@ -62,7 +62,7 @@ export class RefundsResource {
62
62
  * order.
63
63
  */
64
64
  async retrieve(id) {
65
- return this.#http.request("GET", `/refunds/${encodeURIComponent(id)}`);
65
+ return this.#http.request("GET", `/refunds/${pathSegment(id)}`);
66
66
  }
67
67
  /**
68
68
  * `POST /v1/refunds/{id}` — the **metadata** update, and nothing else.
@@ -82,7 +82,7 @@ export class RefundsResource {
82
82
  if (params.metadata !== undefined) {
83
83
  body["metadata"] = params.metadata;
84
84
  }
85
- return this.#http.request("POST", `/refunds/${encodeURIComponent(id)}`, body, options);
85
+ return this.#http.request("POST", `/refunds/${pathSegment(id)}`, body, options);
86
86
  }
87
87
  /** `GET /v1/refunds` — this merchant's refunds, newest first. */
88
88
  async list(params) {
@@ -101,6 +101,6 @@ export class RefundsResource {
101
101
  * rail was ever told to start. See the module doc.
102
102
  */
103
103
  async cancel(id, options) {
104
- return this.#http.request("POST", `/refunds/${encodeURIComponent(id)}/cancel`, {}, options);
104
+ return this.#http.request("POST", `/refunds/${pathSegment(id)}/cancel`, {}, options);
105
105
  }
106
106
  }
@@ -14,3 +14,22 @@
14
14
  * **safe** integer or nothing goes to the wire.
15
15
  */
16
16
  export declare function assertIntegerAmount(amount: number, field?: string): void;
17
+ /**
18
+ * Percent-encodes one merchant-supplied id for a URL path, and refuses the
19
+ * ids escaping cannot keep in place.
20
+ *
21
+ * `encodeURIComponent` keeps a `/`, `?` or `#` inside its segment, but it
22
+ * leaves `.` alone, so `""`, `.` and `..` pass through, and as whole segments
23
+ * `fetch`'s URL parser resolves them before the request leaves:
24
+ * `customers.del("..")` was a `DELETE /v1/`. Escaping the dots would not
25
+ * help, because WHATWG parsing reads `%2E%2E` as `..` too. So an empty or
26
+ * all-dots id throws before anything is sent, the token request included —
27
+ * `sdks/rust`'s `path_segment` refuses the same ids with
28
+ * `Error::InvalidParams`. `...` is refused by decision rather than by the
29
+ * standard: `docs/reference/rails.md` § "A path segment the caller chose"
30
+ * says why.
31
+ *
32
+ * A `TypeError`, like {@link assertIntegerAmount}'s refusal, and not a
33
+ * `VpayError` — the dated gap `docs/sdks/parity.md` records for both.
34
+ */
35
+ export declare function pathSegment(id: string): string;
package/dist/validate.js CHANGED
@@ -18,3 +18,35 @@ export function assertIntegerAmount(amount, field = "amount") {
18
18
  throw new TypeError(`${field} must be a non-negative safe integer in minor units, got ${amount}`);
19
19
  }
20
20
  }
21
+ /**
22
+ * The one message {@link pathSegment}'s refusal carries, whatever the id.
23
+ *
24
+ * Fixed so that it cannot quote the id, and byte-identical to `sdks/rust`'s
25
+ * `DOT_SEGMENT_REFUSAL` behind the same `invalid request parameter id: `
26
+ * prefix that crate's `Error::InvalidParams` displays.
27
+ */
28
+ const DOT_SEGMENT_REFUSAL = "an id that is empty or only dots would address a different endpoint once the URL is parsed; nothing was sent";
29
+ /**
30
+ * Percent-encodes one merchant-supplied id for a URL path, and refuses the
31
+ * ids escaping cannot keep in place.
32
+ *
33
+ * `encodeURIComponent` keeps a `/`, `?` or `#` inside its segment, but it
34
+ * leaves `.` alone, so `""`, `.` and `..` pass through, and as whole segments
35
+ * `fetch`'s URL parser resolves them before the request leaves:
36
+ * `customers.del("..")` was a `DELETE /v1/`. Escaping the dots would not
37
+ * help, because WHATWG parsing reads `%2E%2E` as `..` too. So an empty or
38
+ * all-dots id throws before anything is sent, the token request included —
39
+ * `sdks/rust`'s `path_segment` refuses the same ids with
40
+ * `Error::InvalidParams`. `...` is refused by decision rather than by the
41
+ * standard: `docs/reference/rails.md` § "A path segment the caller chose"
42
+ * says why.
43
+ *
44
+ * A `TypeError`, like {@link assertIntegerAmount}'s refusal, and not a
45
+ * `VpayError` — the dated gap `docs/sdks/parity.md` records for both.
46
+ */
47
+ export function pathSegment(id) {
48
+ if (/^\.*$/u.test(id)) {
49
+ throw new TypeError(`invalid request parameter id: ${DOT_SEGMENT_REFUSAL}`);
50
+ }
51
+ return encodeURIComponent(id);
52
+ }
package/dist/version.d.ts CHANGED
@@ -9,4 +9,4 @@
9
9
  * releases. A hand-written constant has no such landmine — it just needs to
10
10
  * be bumped alongside `package.json`'s own `version` field.
11
11
  */
12
- export declare const SDK_VERSION = "0.9.0";
12
+ export declare const SDK_VERSION = "0.9.1";
package/dist/version.js CHANGED
@@ -9,4 +9,4 @@
9
9
  * releases. A hand-written constant has no such landmine — it just needs to
10
10
  * be bumped alongside `package.json`'s own `version` field.
11
11
  */
12
- export const SDK_VERSION = "0.9.0"; // x-release-please-version
12
+ export const SDK_VERSION = "0.9.1"; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaam-apps/vpay-sdk",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "The Node.js merchant SDK for vpay's /v1 API: OAuth2 private_key_jwt, payment intents, checkout sessions, refunds, events and webhook verification.",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {