@secureprocessing/node 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +262 -2
- package/dist/client.d.ts +94 -0
- package/dist/client.js +196 -0
- package/dist/errors.d.ts +112 -0
- package/dist/errors.js +140 -0
- package/dist/index.d.ts +609 -0
- package/dist/index.js +526 -0
- package/dist/pagination.d.ts +32 -0
- package/dist/pagination.js +52 -0
- package/dist/types.d.ts +892 -0
- package/dist/types.js +3 -0
- package/dist/version.d.ts +8 -0
- package/dist/version.js +9 -0
- package/dist/webhooks.d.ts +22 -0
- package/dist/webhooks.js +79 -0
- package/package.json +32 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Secure Processing
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,263 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @secureprocessing/node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Card payments for Belize. Zero dependencies; runs on Node 20+, Deno, Bun and Cloudflare Workers.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @secureprocessing/node@0.2.1
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The same version is served from our own domain, byte for byte — npm publishes the very file at this
|
|
12
|
+
URL, so either one records the same hash in your lockfile:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install https://secureprocessing.app/sdk/secureprocessing-node-0.2.1.tgz
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
It installs as `@secureprocessing/node` either way, so every `import ... from "@secureprocessing/node"`
|
|
19
|
+
below works unchanged. `pnpm add` takes the same name or URL.
|
|
20
|
+
|
|
21
|
+
Deno, including Supabase Edge Functions:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { SecureProcessing } from "https://secureprocessing.app/sdk/0.2.1/index.js";
|
|
25
|
+
|
|
26
|
+
// Deno 2 imports only from its built-in allow-list unless told otherwise:
|
|
27
|
+
// deno run --allow-net --allow-env --allow-import=secureprocessing.app your-script.ts
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The types come with it: every hosted `.js` names its own `.d.ts`, so `deno check` and the Deno
|
|
31
|
+
language server see them with no `@deno-types` line. (0.1.0 needed one and still lost every type
|
|
32
|
+
behind its first import; 0.2.0 fixes that.)
|
|
33
|
+
|
|
34
|
+
Both URLs are pinned to a version and that version's bytes never change, so the code you reviewed is
|
|
35
|
+
the code that runs.
|
|
36
|
+
|
|
37
|
+
## What is in it
|
|
38
|
+
|
|
39
|
+
`paymentIntents`, `checkoutSessions`, `refunds`, `customers`, `paymentMethods`, `products`, `prices`,
|
|
40
|
+
`paymentLinks`, `taxRates`, `invoices`, `invoiceItems`, `balance`, `balanceTransactions`, `payouts`,
|
|
41
|
+
`reports`, `account`, `events`, `webhookEndpoints`, `riskRules`, `riskLists`, `reviews`, `disputes`,
|
|
42
|
+
`webhooks.constructEvent`, and `autoPaginate` for walking a list — every call the docs site shows,
|
|
43
|
+
which a test in this package checks by reading the docs.
|
|
44
|
+
|
|
45
|
+
## Take a payment
|
|
46
|
+
|
|
47
|
+
Amounts are integers in the currency's minor unit. `6000` is $60.00 — there are no decimals in
|
|
48
|
+
this API, in either direction.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { SecureProcessing } from "@secureprocessing/node";
|
|
52
|
+
|
|
53
|
+
const sp = new SecureProcessing(process.env.SECURE_PROCESSING_KEY!);
|
|
54
|
+
|
|
55
|
+
const intent = await sp.paymentIntents.create({
|
|
56
|
+
amount: 6000,
|
|
57
|
+
currency: "usd",
|
|
58
|
+
// Reaches the bank's own record of the payment, and is what you will see in their portal.
|
|
59
|
+
// Lead with your own reference so you can find it again.
|
|
60
|
+
description: "SCU-B0161 — Blue Hole Two-Tank",
|
|
61
|
+
metadata: { shop_id: "scuba-sensation", booking_id: "SCU-B0161" },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const session = await sp.checkoutSessions.create({
|
|
65
|
+
payment_intent: intent.id,
|
|
66
|
+
success_url: "https://example.com/thanks?session={CHECKOUT_SESSION_ID}",
|
|
67
|
+
cancel_url: "https://example.com/cart",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
redirect(session.url!); // the card is entered on the bank's page, never on yours
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Hold now, charge later
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const intent = await sp.paymentIntents.create({
|
|
77
|
+
amount: 20000,
|
|
78
|
+
currency: "usd",
|
|
79
|
+
capture_method: "manual", // places a hold
|
|
80
|
+
});
|
|
81
|
+
// ...once you know the final amount:
|
|
82
|
+
await sp.paymentIntents.capture(intent.id, { amount_to_capture: 17500 });
|
|
83
|
+
// or release it entirely:
|
|
84
|
+
await sp.paymentIntents.cancel(intent.id);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Refunds
|
|
88
|
+
|
|
89
|
+
Full or partial. A full refund of a payment that has not settled yet is sent as a reversal, which
|
|
90
|
+
is faster and often never reaches the cardholder's statement. You do not choose: we use whichever
|
|
91
|
+
the bank accepts.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
await sp.refunds.create({ payment_intent: intent.id }); // all of it
|
|
95
|
+
await sp.refunds.create({ payment_intent: intent.id, amount: 2000 }); // part of it
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Webhooks
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
const event = await sp.webhooks.constructEvent(
|
|
102
|
+
rawBody, // the RAW string, not a parsed object
|
|
103
|
+
request.headers.get("Secure-Processing-Signature")!,
|
|
104
|
+
process.env.SECURE_PROCESSING_WEBHOOK_SECRET!,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (event.type === "payment_intent.succeeded") {
|
|
108
|
+
const payment = event.data.object;
|
|
109
|
+
// fulfil the order
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
> **Pass the raw body.** `JSON.stringify(JSON.parse(body))` produces different bytes, so the
|
|
114
|
+
> signature will not match. This is the most common reason a first webhook integration fails, and
|
|
115
|
+
> the error message will tell you so.
|
|
116
|
+
|
|
117
|
+
Deliveries are retried for about 35 hours. Handle events idempotently — key on `event.id` — because
|
|
118
|
+
at-least-once delivery means you will occasionally see one twice.
|
|
119
|
+
|
|
120
|
+
## Idempotency
|
|
121
|
+
|
|
122
|
+
Every `POST` carries an `Idempotency-Key` automatically, and it is kept across the client's own
|
|
123
|
+
retries, so a timeout cannot take a second payment. Supply your own when the retry might come from
|
|
124
|
+
elsewhere — a job runner, a queue, a customer pressing the button again:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
await sp.paymentIntents.create(params, { idempotencyKey: `order-${orderId}` });
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Reusing a key with a *different* body is an error rather than a second payment.
|
|
131
|
+
|
|
132
|
+
If the first request with a key is still running, the API answers `409 idempotency_in_progress`.
|
|
133
|
+
The client retries that with the same key, up to `maxRetries` times and never past 30 seconds from
|
|
134
|
+
the first attempt — it cannot run twice; the API answers with the first request's result once it
|
|
135
|
+
has one — and then hands you the error, whose `isRetryable` is `true`. The same 30-second limit
|
|
136
|
+
applies to every retry of a POST, including after a timeout or a dropped connection: past 60
|
|
137
|
+
seconds the API may treat the first request as dead and run a retry again.
|
|
138
|
+
|
|
139
|
+
🔴 **Retrying yourself? Use the same key.** Every error from a POST carries the key it was sent
|
|
140
|
+
with — the one you gave, or the one generated for you — as `err.idempotencyKey`, on
|
|
141
|
+
`SecureProcessingError` and `SecureProcessingConnectionError` alike. Calling the method again
|
|
142
|
+
without it sends a NEW key, which the API treats as a new request: a second charge or refund if the
|
|
143
|
+
first one reached the bank.
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
try {
|
|
147
|
+
await sp.refunds.create({ payment_intent: id });
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if ((err instanceof SecureProcessingError && err.isRetryable) || err instanceof SecureProcessingConnectionError) {
|
|
150
|
+
await sp.refunds.create({ payment_intent: id }, { idempotencyKey: err.idempotencyKey }); // never a new key
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`409 payment_in_progress` is different: cancelling a payment the customer may be paying right now
|
|
156
|
+
(on the bank's page, or in 3-D Secure). Nothing was changed, the message says when to try again,
|
|
157
|
+
and `err.isPaymentInProgress` is `true`. The client never retries it — it is minutes, not a backoff.
|
|
158
|
+
|
|
159
|
+
`400 refund_not_yet_possible` (`err.isRefundNotYetPossible`) is a partial refund on a payment the
|
|
160
|
+
bank has not settled yet. The bank definitely refused and nothing was refunded. It is **the one
|
|
161
|
+
error to retry under a NEW idempotency key**, once the payment has settled — usually by the next
|
|
162
|
+
business day: the original key stays bound to this answer for 24 hours and would only replay it.
|
|
163
|
+
Calling the method again WITHOUT `idempotencyKey` already sends a fresh key, so that is all the
|
|
164
|
+
retry needs. If nothing has been refunded yet, a full refund can be made now.
|
|
165
|
+
|
|
166
|
+
🔴 `500 outcome_unknown` (`err.isOutcomeUnknown`) means the request reached the bank and the result
|
|
167
|
+
could not be confirmed: **the money may have moved.** Never repeat it under a new idempotency key —
|
|
168
|
+
that is a second charge or refund. Retrying under the same key (`err.idempotencyKey`, which the
|
|
169
|
+
message also names) is safe but gains nothing now: usually it replays this answer, and when the
|
|
170
|
+
message says the original "has not recorded its result yet", a retry later returns that result. So
|
|
171
|
+
the client does not retry it, and `isRetryable` is `false`. Retrieve the payment (or list its refunds) and check
|
|
172
|
+
its `status`; it settles on its own and its webhook follows.
|
|
173
|
+
|
|
174
|
+
A `202` is the same situation answered with an object: `invoices.pay` (charge sent, outcome not
|
|
175
|
+
back) and `reviews.refund` (refund pending, review still open). Tell it from a `200` with
|
|
176
|
+
`result.lastResponse.statusCode === 202`.
|
|
177
|
+
|
|
178
|
+
## Errors
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { SecureProcessingError } from "@secureprocessing/node";
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
await sp.refunds.create({ payment_intent: id });
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (err instanceof SecureProcessingError) {
|
|
187
|
+
console.error(err.type, err.code, err.declineCode, err.requestId);
|
|
188
|
+
if (err.isRetryable) { /* try again later, with { idempotencyKey: err.idempotencyKey } */ }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Quote `err.requestId` to support and they can find the exact request.
|
|
194
|
+
|
|
195
|
+
A `403 insufficient_permissions` names what your API key or role lacks in `err.requiredCapability`
|
|
196
|
+
(for example `payments:refund`); ask the account's owner for it — retrying will not help. A
|
|
197
|
+
`404 resource_missing` caused by an id in the request body names that field in `err.param`. A
|
|
198
|
+
request body may be at most 1 MB (`413 body_too_large`); none of the API's own objects come close.
|
|
199
|
+
None of these three is retried.
|
|
200
|
+
|
|
201
|
+
A successful result carries its request id too, on a non-enumerable `lastResponse` (it does not
|
|
202
|
+
appear in `JSON.stringify`, spreads or `Object.keys` — so a copy such as `{ ...intent }` does not
|
|
203
|
+
have it at runtime, whatever TypeScript says; read it from the object the call returned):
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
const intent = await sp.paymentIntents.create({ amount: 6000, currency: "usd" });
|
|
207
|
+
intent.lastResponse.requestId; // the X-Request-Id header
|
|
208
|
+
intent.lastResponse.idempotencyKey; // what this POST was sent with
|
|
209
|
+
intent.lastResponse.idempotentReplayed; // true if the API replayed an earlier answer
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Every page of a list
|
|
213
|
+
|
|
214
|
+
List calls return one page. `autoPaginate` walks all of them, fetching each only when you reach it:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
import { autoPaginate } from "@secureprocessing/node";
|
|
218
|
+
|
|
219
|
+
let total = 0;
|
|
220
|
+
for await (const line of autoPaginate((page) => sp.payouts.listTransactions(payoutId, page))) {
|
|
221
|
+
total += line.net;
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Currencies
|
|
226
|
+
|
|
227
|
+
Price in `usd` or `bzd`. Settlement is in BZD, and the payment object records what the card was
|
|
228
|
+
actually charged and at what rate:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
intent.amount; // 6000 — what you asked for, in USD cents
|
|
232
|
+
intent.settlement_amount; // 12000 — what the card was charged, in BZD cents
|
|
233
|
+
intent.settlement_currency; // "BZD"
|
|
234
|
+
intent.fx_rate; // 2
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The rate is frozen when the payment is taken, so a later capture or refund uses that rate and not
|
|
238
|
+
whatever today's is.
|
|
239
|
+
|
|
240
|
+
## Test mode
|
|
241
|
+
|
|
242
|
+
Keys beginning `sk_test_` never move real money. Test cards:
|
|
243
|
+
|
|
244
|
+
| Number | Expiry | Result |
|
|
245
|
+
|---|---|---|
|
|
246
|
+
| `5555555555555599` | 12/34 | Succeeds |
|
|
247
|
+
| `4111111111111111` | 12/26 | Succeeds |
|
|
248
|
+
| `5168494895055780` | 12/26 | Declined |
|
|
249
|
+
|
|
250
|
+
CVC `123` for all of them. These are the bank's sandbox cards as they stood when this version was
|
|
251
|
+
released, and two of them expire 12/26; the current list is always at
|
|
252
|
+
<https://docs.secureprocessing.app/test-cards>.
|
|
253
|
+
|
|
254
|
+
## Options
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
new SecureProcessing(apiKey, {
|
|
258
|
+
baseUrl: "https://api.secureprocessing.app",
|
|
259
|
+
timeout: 30_000,
|
|
260
|
+
maxRetries: 2,
|
|
261
|
+
fetch: myFetch, // e.g. a Worker's fetch, or a test double
|
|
262
|
+
});
|
|
263
|
+
```
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export interface ClientOptions {
|
|
2
|
+
/** Defaults to the production API. */
|
|
3
|
+
baseUrl?: string;
|
|
4
|
+
/** `test` or `live`. Only needed when authenticating with a dashboard session. */
|
|
5
|
+
mode?: "test" | "live";
|
|
6
|
+
/** Supply your own fetch — a Worker's, a test double, or one with a proxy. */
|
|
7
|
+
fetch?: typeof fetch;
|
|
8
|
+
/** Milliseconds before giving up on a request. */
|
|
9
|
+
timeout?: number;
|
|
10
|
+
/** How many times to retry a request that failed in a way that might succeed. */
|
|
11
|
+
maxRetries?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface RequestOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Make this request safe to retry.
|
|
16
|
+
*
|
|
17
|
+
* One is generated for every POST if you do not supply one, so a retry after a timeout cannot
|
|
18
|
+
* take a second payment. Supply your own when the retry might come from somewhere else — a job
|
|
19
|
+
* runner, a queue, a user pressing the button again.
|
|
20
|
+
*/
|
|
21
|
+
idempotencyKey?: string;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* What came back with a result, besides the object itself. Read it as `result.lastResponse`.
|
|
26
|
+
*
|
|
27
|
+
* ```ts
|
|
28
|
+
* const intent = await sp.paymentIntents.create({ amount: 6000, currency: "usd" });
|
|
29
|
+
* log.info("created", intent.id, intent.lastResponse.requestId); // quote this to support
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export interface ResponseMetadata {
|
|
33
|
+
/** The API's `X-Request-Id` for this call: what support needs to find it in our logs. */
|
|
34
|
+
requestId: string | undefined;
|
|
35
|
+
statusCode: number;
|
|
36
|
+
headers: Headers;
|
|
37
|
+
/** The `Idempotency-Key` this POST was sent with — yours, or the one generated for you. */
|
|
38
|
+
idempotencyKey: string | undefined;
|
|
39
|
+
/** True when the API replayed the stored answer to an earlier request with the same key. */
|
|
40
|
+
idempotentReplayed: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* An API object with its response metadata attached, non-enumerably, as `lastResponse`.
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ Non-enumerable means a copy loses it: `{ ...intent }` or `structuredClone(intent)` has no
|
|
46
|
+
* `lastResponse` at runtime, although TypeScript's spread type still claims one. Read it from the
|
|
47
|
+
* object the call returned.
|
|
48
|
+
*/
|
|
49
|
+
export type ApiResponse<T> = T & {
|
|
50
|
+
readonly lastResponse: ResponseMetadata;
|
|
51
|
+
};
|
|
52
|
+
export declare class HttpClient {
|
|
53
|
+
private readonly apiKey;
|
|
54
|
+
private readonly baseUrl;
|
|
55
|
+
private readonly mode;
|
|
56
|
+
private readonly fetchImpl;
|
|
57
|
+
private readonly timeout;
|
|
58
|
+
private readonly maxRetries;
|
|
59
|
+
constructor(apiKey: string, options?: ClientOptions);
|
|
60
|
+
request<T>(method: "GET" | "POST" | "DELETE", path: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>>;
|
|
61
|
+
/**
|
|
62
|
+
* 🔴 Every retry of a keyed request (a POST) goes out within RETRY_WINDOW_MS of the FIRST
|
|
63
|
+
* attempt, or not at all — a 409 `idempotency_in_progress`, a 5xx and a connection failure alike.
|
|
64
|
+
*
|
|
65
|
+
* The API's idempotency store (packages/db, IN_PROGRESS_GRACE_SECONDS = 60) presumes a request
|
|
66
|
+
* whose key has been locked for 60 seconds has died, and lets the next caller with that key run
|
|
67
|
+
* the operation again. Inside the window a retry can only be answered with another 409 or with
|
|
68
|
+
* the first request's stored response; past it, a retry is a second execution if the first was
|
|
69
|
+
* merely slow. Before this applied to connection failures too, the defaults (30s timeout, two
|
|
70
|
+
* retries, backoff) could send a third attempt at about 63 seconds. So the client gives up at
|
|
71
|
+
* half the server's grace and hands the error back, carrying `idempotencyKey` so the caller can
|
|
72
|
+
* retry under the same key when they choose. A first attempt that timed out has already spent the
|
|
73
|
+
* window. A GET has no key and nothing to run twice, so only `maxRetries` limits it.
|
|
74
|
+
*/
|
|
75
|
+
private mayRetry;
|
|
76
|
+
get<T>(path: string, query?: Record<string, unknown>): Promise<ApiResponse<T>>;
|
|
77
|
+
/**
|
|
78
|
+
* A GET whose body you want as a STREAM, not as parsed JSON.
|
|
79
|
+
*
|
|
80
|
+
* 🔴 Deliberately outside `request()`, and not retried. `request()` calls `response.json()`,
|
|
81
|
+
* which reads the whole body into memory — on a year of payments that is the point of the export
|
|
82
|
+
* defeated, and on a Worker it is the memory limit. Retrying is wrong here for a second reason:
|
|
83
|
+
* a retry can only happen after the first attempt's body has been abandoned, and a caller who
|
|
84
|
+
* has already started piping bytes to a file would have them silently interleaved with a second
|
|
85
|
+
* attempt's.
|
|
86
|
+
*
|
|
87
|
+
* The caller gets the `Response`: pipe `response.body`, or `await response.text()` for a small
|
|
88
|
+
* one. A non-2xx still throws a `SecureProcessingError` with the API's envelope, because the
|
|
89
|
+
* error path IS JSON and is small.
|
|
90
|
+
*/
|
|
91
|
+
raw(path: string, query?: Record<string, unknown>, accept?: string): Promise<Response>;
|
|
92
|
+
post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>>;
|
|
93
|
+
delete<T>(path: string): Promise<ApiResponse<T>>;
|
|
94
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// @ts-self-types="./client.d.ts"
|
|
2
|
+
import { SecureProcessingConnectionError, SecureProcessingError } from "./errors.js";
|
|
3
|
+
import { VERSION } from "./version.js";
|
|
4
|
+
const REQUEST_ID_HEADER = "X-Request-Id";
|
|
5
|
+
const DEFAULT_BASE_URL = "https://api.secureprocessing.app";
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
7
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
8
|
+
/** Half of the API's IN_PROGRESS_GRACE_SECONDS (60). See `mayRetry`. */
|
|
9
|
+
const RETRY_WINDOW_MS = 30_000;
|
|
10
|
+
export class HttpClient {
|
|
11
|
+
apiKey;
|
|
12
|
+
baseUrl;
|
|
13
|
+
mode;
|
|
14
|
+
fetchImpl;
|
|
15
|
+
timeout;
|
|
16
|
+
maxRetries;
|
|
17
|
+
constructor(apiKey, options = {}) {
|
|
18
|
+
if (!apiKey)
|
|
19
|
+
throw new Error("An API key is required");
|
|
20
|
+
this.apiKey = apiKey;
|
|
21
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
22
|
+
this.mode = options.mode;
|
|
23
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
24
|
+
this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
25
|
+
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
26
|
+
}
|
|
27
|
+
async request(method, path, body, options = {}) {
|
|
28
|
+
const headers = {
|
|
29
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
30
|
+
Accept: "application/json",
|
|
31
|
+
"User-Agent": `secureprocessing-node/${VERSION}`,
|
|
32
|
+
};
|
|
33
|
+
if (this.mode)
|
|
34
|
+
headers["Secure-Processing-Mode"] = this.mode;
|
|
35
|
+
// Exactly this string, lower-case: the API answers `415 unsupported_media_type` to a body whose
|
|
36
|
+
// type is not `application/json` (or a lower-case `+json` type). A test pins it.
|
|
37
|
+
if (body !== undefined)
|
|
38
|
+
headers["Content-Type"] = "application/json";
|
|
39
|
+
// 🔴 Generated once, OUTSIDE the retry loop. A key regenerated per attempt would defeat the
|
|
40
|
+
// entire purpose: the retry would look like a new request and could take a second payment.
|
|
41
|
+
if (method === "POST") {
|
|
42
|
+
headers["Idempotency-Key"] = options.idempotencyKey ?? crypto.randomUUID();
|
|
43
|
+
}
|
|
44
|
+
const idempotencyKey = headers["Idempotency-Key"];
|
|
45
|
+
const startedAt = Date.now();
|
|
46
|
+
let lastError;
|
|
47
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
|
|
48
|
+
if (attempt > 0)
|
|
49
|
+
await sleep(backoffMs(attempt));
|
|
50
|
+
try {
|
|
51
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
52
|
+
method,
|
|
53
|
+
headers,
|
|
54
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
55
|
+
signal: options.signal ?? AbortSignal.timeout(this.timeout),
|
|
56
|
+
});
|
|
57
|
+
if (response.ok) {
|
|
58
|
+
return withLastResponse((await response.json()), response, idempotencyKey);
|
|
59
|
+
}
|
|
60
|
+
throw await errorFrom(response, idempotencyKey);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (err instanceof SecureProcessingError) {
|
|
64
|
+
if (!err.isRetryable || !this.mayRetry(attempt, startedAt, idempotencyKey))
|
|
65
|
+
throw err;
|
|
66
|
+
lastError = err;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// A connection failure is safe to retry precisely BECAUSE the idempotency key is fixed:
|
|
70
|
+
// if the first attempt reached us, the retry replays its answer rather than repeating it.
|
|
71
|
+
lastError = new SecureProcessingConnectionError(`Could not reach the Secure Processing API: ${err.message}`, err, idempotencyKey);
|
|
72
|
+
if (!this.mayRetry(attempt, startedAt, idempotencyKey))
|
|
73
|
+
throw lastError;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
throw lastError;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* 🔴 Every retry of a keyed request (a POST) goes out within RETRY_WINDOW_MS of the FIRST
|
|
80
|
+
* attempt, or not at all — a 409 `idempotency_in_progress`, a 5xx and a connection failure alike.
|
|
81
|
+
*
|
|
82
|
+
* The API's idempotency store (packages/db, IN_PROGRESS_GRACE_SECONDS = 60) presumes a request
|
|
83
|
+
* whose key has been locked for 60 seconds has died, and lets the next caller with that key run
|
|
84
|
+
* the operation again. Inside the window a retry can only be answered with another 409 or with
|
|
85
|
+
* the first request's stored response; past it, a retry is a second execution if the first was
|
|
86
|
+
* merely slow. Before this applied to connection failures too, the defaults (30s timeout, two
|
|
87
|
+
* retries, backoff) could send a third attempt at about 63 seconds. So the client gives up at
|
|
88
|
+
* half the server's grace and hands the error back, carrying `idempotencyKey` so the caller can
|
|
89
|
+
* retry under the same key when they choose. A first attempt that timed out has already spent the
|
|
90
|
+
* window. A GET has no key and nothing to run twice, so only `maxRetries` limits it.
|
|
91
|
+
*/
|
|
92
|
+
mayRetry(attempt, startedAt, idempotencyKey) {
|
|
93
|
+
if (attempt >= this.maxRetries)
|
|
94
|
+
return false;
|
|
95
|
+
if (idempotencyKey === undefined)
|
|
96
|
+
return true;
|
|
97
|
+
return Date.now() - startedAt + backoffMs(attempt + 1) < RETRY_WINDOW_MS;
|
|
98
|
+
}
|
|
99
|
+
get(path, query) {
|
|
100
|
+
return this.request("GET", path + encodeQuery(query));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A GET whose body you want as a STREAM, not as parsed JSON.
|
|
104
|
+
*
|
|
105
|
+
* 🔴 Deliberately outside `request()`, and not retried. `request()` calls `response.json()`,
|
|
106
|
+
* which reads the whole body into memory — on a year of payments that is the point of the export
|
|
107
|
+
* defeated, and on a Worker it is the memory limit. Retrying is wrong here for a second reason:
|
|
108
|
+
* a retry can only happen after the first attempt's body has been abandoned, and a caller who
|
|
109
|
+
* has already started piping bytes to a file would have them silently interleaved with a second
|
|
110
|
+
* attempt's.
|
|
111
|
+
*
|
|
112
|
+
* The caller gets the `Response`: pipe `response.body`, or `await response.text()` for a small
|
|
113
|
+
* one. A non-2xx still throws a `SecureProcessingError` with the API's envelope, because the
|
|
114
|
+
* error path IS JSON and is small.
|
|
115
|
+
*/
|
|
116
|
+
async raw(path, query, accept = "text/csv") {
|
|
117
|
+
const headers = {
|
|
118
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
119
|
+
Accept: accept,
|
|
120
|
+
"User-Agent": `secureprocessing-node/${VERSION}`,
|
|
121
|
+
};
|
|
122
|
+
if (this.mode)
|
|
123
|
+
headers["Secure-Processing-Mode"] = this.mode;
|
|
124
|
+
let response;
|
|
125
|
+
try {
|
|
126
|
+
response = await this.fetchImpl(`${this.baseUrl}${path}${encodeQuery(query)}`, {
|
|
127
|
+
method: "GET",
|
|
128
|
+
headers,
|
|
129
|
+
signal: AbortSignal.timeout(this.timeout),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
throw new SecureProcessingConnectionError(`Could not reach the Secure Processing API: ${err.message}`, err);
|
|
134
|
+
}
|
|
135
|
+
if (response.ok)
|
|
136
|
+
return response;
|
|
137
|
+
throw await errorFrom(response);
|
|
138
|
+
}
|
|
139
|
+
post(path, body, options) {
|
|
140
|
+
return this.request("POST", path, body ?? {}, options);
|
|
141
|
+
}
|
|
142
|
+
delete(path) {
|
|
143
|
+
return this.request("DELETE", path);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** The API's envelope when there is one, and an honest stand-in when a proxy answered instead. */
|
|
147
|
+
async function errorFrom(response, idempotencyKey) {
|
|
148
|
+
const payload = (await response.json().catch(() => null));
|
|
149
|
+
const requestId = response.headers.get(REQUEST_ID_HEADER);
|
|
150
|
+
return payload?.error
|
|
151
|
+
? new SecureProcessingError(response.status, payload, requestId, idempotencyKey)
|
|
152
|
+
: new SecureProcessingError(response.status, { error: { type: "api_error", code: "unexpected_response", message: `HTTP ${response.status}` } }, requestId, idempotencyKey);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Attach the response's metadata as a NON-ENUMERABLE `lastResponse`, the convention Stripe's Node
|
|
156
|
+
* SDK uses for the same problem: the request id of a SUCCESSFUL call lives only in a header, and
|
|
157
|
+
* returning `{ data, response }` instead would break every caller. Non-enumerable means
|
|
158
|
+
* `JSON.stringify`, spreading, `Object.keys` and deep-equality checks see exactly the API's object,
|
|
159
|
+
* so storing or comparing a result behaves as it did in 0.1.0.
|
|
160
|
+
*/
|
|
161
|
+
function withLastResponse(body, response, idempotencyKey) {
|
|
162
|
+
if (body === null || typeof body !== "object")
|
|
163
|
+
return body;
|
|
164
|
+
const lastResponse = {
|
|
165
|
+
requestId: response.headers.get(REQUEST_ID_HEADER) ?? undefined,
|
|
166
|
+
statusCode: response.status,
|
|
167
|
+
headers: response.headers,
|
|
168
|
+
idempotencyKey,
|
|
169
|
+
idempotentReplayed: response.headers.get("Idempotent-Replayed") === "true",
|
|
170
|
+
};
|
|
171
|
+
Object.defineProperty(body, "lastResponse", { value: lastResponse, enumerable: false, writable: false });
|
|
172
|
+
return body;
|
|
173
|
+
}
|
|
174
|
+
/** Nested keys become `metadata[shop_id]=…`, which is what the list endpoints expect. */
|
|
175
|
+
function encodeQuery(query) {
|
|
176
|
+
if (!query)
|
|
177
|
+
return "";
|
|
178
|
+
const params = new URLSearchParams();
|
|
179
|
+
for (const [key, value] of Object.entries(query)) {
|
|
180
|
+
if (value === undefined || value === null)
|
|
181
|
+
continue;
|
|
182
|
+
if (typeof value === "object") {
|
|
183
|
+
for (const [inner, innerValue] of Object.entries(value)) {
|
|
184
|
+
if (innerValue !== undefined && innerValue !== null)
|
|
185
|
+
params.append(`${key}[${inner}]`, String(innerValue));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
params.append(key, String(value));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const encoded = params.toString();
|
|
193
|
+
return encoded ? `?${encoded}` : "";
|
|
194
|
+
}
|
|
195
|
+
const backoffMs = (attempt) => Math.min(1000 * 2 ** (attempt - 1), 8000) + Math.floor(Math.random() * 250);
|
|
196
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|