@pulsebyshiga/node 0.2.0
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 +301 -0
- package/dist/auth.d.ts +27 -0
- package/dist/auth.js +12 -0
- package/dist/client.d.ts +30 -0
- package/dist/client.js +121 -0
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +27 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +45 -0
- package/dist/resources/banks.d.ts +10 -0
- package/dist/resources/banks.js +21 -0
- package/dist/resources/collectionOrders.d.ts +12 -0
- package/dist/resources/collectionOrders.js +36 -0
- package/dist/resources/collectionSessions.d.ts +16 -0
- package/dist/resources/collectionSessions.js +19 -0
- package/dist/resources/offramp.d.ts +11 -0
- package/dist/resources/offramp.js +22 -0
- package/dist/resources/onramp.d.ts +11 -0
- package/dist/resources/onramp.js +22 -0
- package/dist/resources/quotes.d.ts +11 -0
- package/dist/resources/quotes.js +17 -0
- package/dist/resources/rates.d.ts +38 -0
- package/dist/resources/rates.js +30 -0
- package/dist/types.d.ts +446 -0
- package/dist/types.js +12 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +6 -0
- package/dist/webhooks.d.ts +49 -0
- package/dist/webhooks.js +135 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shiga Digital
|
|
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
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# @pulsebyshiga/node
|
|
2
|
+
|
|
3
|
+
Official Node.js SDK for the Pulse API: quotes, offramp and onramp orders, bank
|
|
4
|
+
name-enquiry, collection sessions, and webhook verification. Idempotent requests,
|
|
5
|
+
automatic retries, and typed errors are built in.
|
|
6
|
+
|
|
7
|
+
> **Status: pre-release (0.1.0).** The quote/offramp/onramp/banks surface is reconciled
|
|
8
|
+
> 1:1 against the live engine contract
|
|
9
|
+
> ([Swagger](https://engine-api.shiga.io/swagger/public/index.html)). The collection-session
|
|
10
|
+
> and webhook surface is the product extension landing in the engine — see
|
|
11
|
+
> [docs/engine-gap.md](../../docs/engine-gap.md).
|
|
12
|
+
|
|
13
|
+
**New to the SDK?** The [backend integration guide](../../docs/backend-integration.md) walks
|
|
14
|
+
through the whole server-side pattern — service methods, typed-error handling, webhooks, and
|
|
15
|
+
handing the `cs_` session token to the browser component.
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- Node.js ≥ 18.17
|
|
20
|
+
- ESM (`import`) — this package does not ship a CommonJS build
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install @pulsebyshiga/node
|
|
26
|
+
# pnpm add @pulsebyshiga/node · yarn add @pulsebyshiga/node
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import Pulse, { useApiKey } from '@pulsebyshiga/node';
|
|
33
|
+
|
|
34
|
+
const pulse = new Pulse(useApiKey(process.env.PULSE_API_KEY), {
|
|
35
|
+
webhookSecret: process.env.PULSE_WEBHOOK_SECRET,
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`useApiKey(...)` declares the server credential (a bare string still works for back-compat).
|
|
40
|
+
The key is sent as `X-API-Key` (the engine's auth scheme) and as a `Bearer` token (the
|
|
41
|
+
session surface's scheme) — one key, both means. Default host is
|
|
42
|
+
`https://engine-api.shiga.io`; override with `baseUrl` for local development or a future
|
|
43
|
+
sandbox.
|
|
44
|
+
|
|
45
|
+
### Quote-first flow (engine)
|
|
46
|
+
|
|
47
|
+
Everything starts with a locked price; orders are created from the `quote.id` before
|
|
48
|
+
`expires_at` passes.
|
|
49
|
+
|
|
50
|
+
**Just the rate (no order):** `rates.preview` wraps a quote for display — nothing settles
|
|
51
|
+
and no order is created.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
const rate = await pulse.rates.preview({
|
|
55
|
+
from: 'USDC',
|
|
56
|
+
to: 'NGN',
|
|
57
|
+
amount: '100.00',
|
|
58
|
+
network: 'BASE',
|
|
59
|
+
});
|
|
60
|
+
// → { rate, youSend, youReceive, expiresAt, quoteId }
|
|
61
|
+
// Proceed to an order with quoteId to transact at exactly this locked rate.
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Offramp — crypto in, fiat out** (the collection direction):
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
const quote = await pulse.quotes.create({
|
|
68
|
+
amount: '100.50',
|
|
69
|
+
source_currency: 'USDC',
|
|
70
|
+
destination_currency: 'NGN',
|
|
71
|
+
network: 'BASE', // BASE | POLYGON | ARBITRUM | OPTIMISM | ETHEREUM | BSC
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Verify the payout account first (name enquiry).
|
|
75
|
+
const account = await pulse.banks.resolve({ account_number: '0123456789', bank_code: '000013' });
|
|
76
|
+
|
|
77
|
+
const order = await pulse.offramp.create({
|
|
78
|
+
quote_id: quote.id,
|
|
79
|
+
customer_id: 'your_user_123',
|
|
80
|
+
customer_name: account.account_name,
|
|
81
|
+
customer_email: 'user@example.com',
|
|
82
|
+
account_number: account.account_number,
|
|
83
|
+
account_name: account.account_name,
|
|
84
|
+
bank_code: account.bank_code,
|
|
85
|
+
bank_name: account.bank_name,
|
|
86
|
+
});
|
|
87
|
+
// Show order.deposit_address — the user sends quote.source_amount there.
|
|
88
|
+
// Poll pulse.offramp.retrieve(order.id) until a terminal status.
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Onramp — fiat in, crypto out:**
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
const quote = await pulse.quotes.create({
|
|
95
|
+
amount: '50000.00',
|
|
96
|
+
source_currency: 'NGN',
|
|
97
|
+
destination_currency: 'USDC',
|
|
98
|
+
network: 'BASE',
|
|
99
|
+
});
|
|
100
|
+
const order = await pulse.onramp.create({
|
|
101
|
+
quote_id: quote.id,
|
|
102
|
+
customer_id: 'your_user_123',
|
|
103
|
+
customer_name: 'Ada Lovelace',
|
|
104
|
+
customer_email: 'user@example.com',
|
|
105
|
+
destination_address: '0xUserWallet…',
|
|
106
|
+
});
|
|
107
|
+
// order.account is the virtual bank account to pay (amount + expires_at).
|
|
108
|
+
// Poll pulse.onramp.retrieve(order.id) until a terminal status.
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Banks:** `pulse.banks.list()` returns supported banks (name + NIP code).
|
|
112
|
+
|
|
113
|
+
## Product surface — collection sessions (landing in the engine)
|
|
114
|
+
|
|
115
|
+
The embedded components run on this surface; the dev mock implements it today and
|
|
116
|
+
[docs/engine-gap.md](../../docs/engine-gap.md) tracks its adoption into the engine.
|
|
117
|
+
|
|
118
|
+
### Create a collection session
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// Naira gate — funds settle to the user's named virtual account.
|
|
122
|
+
// First use provisions the account from BVN/NIN; returning users pass the reference.
|
|
123
|
+
const session = await pulse.collectionSessions.create(
|
|
124
|
+
{
|
|
125
|
+
gate: 'naira',
|
|
126
|
+
target: { currency: 'NGN', amount: '1000000.00' },
|
|
127
|
+
asset: 'USDT',
|
|
128
|
+
network: 'solana',
|
|
129
|
+
user_ref: 'your_user_123',
|
|
130
|
+
account: { provision: true, bvn: '...', nin: '...' },
|
|
131
|
+
},
|
|
132
|
+
{ idempotencyKey: `fund-${depositId}` },
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// USD gate — funds settle to your omnibus account; no per-user identity.
|
|
136
|
+
const usdSession = await pulse.collectionSessions.create({
|
|
137
|
+
gate: 'usd',
|
|
138
|
+
target: { currency: 'USD', amount: '500.00' },
|
|
139
|
+
asset: 'USDC',
|
|
140
|
+
network: 'base',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Hand session.session_token (cs_*) to your frontend. Nothing else.
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Direction.** Sessions default to `offramp` (crypto in → fiat out). Pass
|
|
147
|
+
`direction: 'onramp'` for the reverse — the user pays a virtual bank account
|
|
148
|
+
(carried on the order as `pay_account`) and receives crypto at a wallet. The
|
|
149
|
+
embedded component renders the matching UI automatically (bank-transfer
|
|
150
|
+
instructions instead of a deposit address/QR).
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
const onrampSession = await pulse.collectionSessions.create({
|
|
154
|
+
gate: 'naira',
|
|
155
|
+
direction: 'onramp',
|
|
156
|
+
target: { currency: 'NGN', amount: '50000.00' },
|
|
157
|
+
asset: 'USDT',
|
|
158
|
+
network: 'base',
|
|
159
|
+
user_ref: 'your_user_123',
|
|
160
|
+
account: { provision: false, virtual_account_ref: 'va_...' },
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Query orders
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const order = await pulse.collectionOrders.retrieve(orderId); // ground truth for one order
|
|
168
|
+
const page = await pulse.collectionOrders.list({ gate: 'naira', status: 'completed', limit: 50 });
|
|
169
|
+
const relocked = await pulse.collectionOrders.refreshQuote(orderId); // new quote window for an expired order
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Webhooks
|
|
173
|
+
|
|
174
|
+
Deliveries are signed with a `Pulse-Signature` header:
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
Pulse-Signature: t=<unix-seconds>,v1=<hex hmac-sha256 of "<t>.<raw body>">
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Verification requires the **raw** request body — do not parse JSON first.
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import express from 'express';
|
|
184
|
+
|
|
185
|
+
app.post('/webhooks/pulse', express.raw({ type: '*/*' }), async (req, res) => {
|
|
186
|
+
const event = await pulse.webhooks.verifyOnce(req.body, req.header('pulse-signature') ?? '');
|
|
187
|
+
if (event === null) return res.sendStatus(200); // redelivery — already processed
|
|
188
|
+
|
|
189
|
+
switch (event.type) {
|
|
190
|
+
case 'disbursement.completed':
|
|
191
|
+
await creditPosition(event.data.order_id, event.data.disbursement.settlement_ref);
|
|
192
|
+
break;
|
|
193
|
+
case 'collection.amount.underpaid':
|
|
194
|
+
await handleShortfall(event.data); // { expected, received, asset, network, tx_hash }
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
res.sendStatus(200);
|
|
198
|
+
});
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
| Method | Behavior |
|
|
202
|
+
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
203
|
+
| `webhooks.verify(payload, header, secret?, options?)` | Constant-time HMAC check + replay-window check (default tolerance 300 s). Returns the typed event or throws `SignatureVerificationError`. |
|
|
204
|
+
| `webhooks.verifyOnce(payload, header, secret?, options?)` | Everything `verify` does, plus de-duplication on the event id. Returns `null` for a redelivery. |
|
|
205
|
+
|
|
206
|
+
Delivery is **at-least-once**: handlers must tolerate duplicates. `verifyOnce` de-duplicates
|
|
207
|
+
in-process by default; multi-instance deployments must back it with a shared store:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import Pulse, { type ProcessedEventStore } from '@pulsebyshiga/node';
|
|
211
|
+
|
|
212
|
+
const store: ProcessedEventStore = {
|
|
213
|
+
has: (id) => redis.exists(`pulse:evt:${id}`).then(Boolean),
|
|
214
|
+
add: (id) => redis.set(`pulse:evt:${id}`, '1', 'EX', 86_400).then(() => undefined),
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const pulse = new Pulse(apiKey, { webhookSecret, webhookEventStore: store });
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Event types
|
|
221
|
+
|
|
222
|
+
| Event | Fires when |
|
|
223
|
+
| ------------------------------------------ | -------------------------------------------------------------------------------------------- |
|
|
224
|
+
| `collection.order.created` | Session created; the full order is attached. |
|
|
225
|
+
| `collection.deposit.detected` | On-chain deposit seen, not yet confirmed. |
|
|
226
|
+
| `collection.deposit.confirmed` | Deposit confirmed at the required depth. |
|
|
227
|
+
| `collection.deposit.wrong_chain` | Funds arrived on a network other than the order's; the order stays payable on the right one. |
|
|
228
|
+
| `collection.amount.underpaid` / `overpaid` | Confirmed amount differs from the quote; carries `expected` vs `received`. |
|
|
229
|
+
| `disbursement.completed` | Fiat settled. **The only event that credits a user.** |
|
|
230
|
+
| `collection.order.expired` | Quote window elapsed without payment. |
|
|
231
|
+
| `collection.order.failed` | Terminal failure; carries a `reason`. |
|
|
232
|
+
|
|
233
|
+
**Credit positions from `disbursement.completed` only.** Client-side callbacks such as the
|
|
234
|
+
embed's `onSuccess` are UX signals and can be spoofed; the signed webhook cannot.
|
|
235
|
+
|
|
236
|
+
## Reliability
|
|
237
|
+
|
|
238
|
+
- **Idempotency** — every POST carries an `Idempotency-Key` (yours via `idempotencyKey`,
|
|
239
|
+
otherwise a generated UUID), so retries can never double-create an order.
|
|
240
|
+
- **Retries** — network errors, HTTP 429 and 5xx are retried with exponential backoff
|
|
241
|
+
(2 retries, 250 ms base delay by default). Other 4xx responses throw immediately.
|
|
242
|
+
- **Identification, not telemetry** — every request carries a static
|
|
243
|
+
`X-Pulse-SDK: pulse-node/<version> node/<version> (<platform>)` header so the API can
|
|
244
|
+
measure adoption and error rates per SDK version server-side. The SDK never emits
|
|
245
|
+
telemetry from your infrastructure.
|
|
246
|
+
|
|
247
|
+
## Configuration
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
new Pulse(apiKey, options?)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
| Option | Type | Default | Description |
|
|
254
|
+
| ------------------- | --------------------- | ----------------------- | ------------------------------------------------------------------- |
|
|
255
|
+
| `baseUrl` | `string` | derived from key prefix | API host override (local mock, staging). |
|
|
256
|
+
| `maxRetries` | `number` | `2` | Retry count for network errors / 429 / 5xx. |
|
|
257
|
+
| `retryDelayMs` | `number` | `250` | Base backoff delay, doubled per attempt. |
|
|
258
|
+
| `fetchImpl` | `FetchLike` | global `fetch` | Injectable transport for tests/instrumentation. |
|
|
259
|
+
| `webhookSecret` | `string` | — | Default secret for `webhooks.verify*`; can also be passed per call. |
|
|
260
|
+
| `webhookEventStore` | `ProcessedEventStore` | in-memory | Shared de-duplication store for `verifyOnce`. |
|
|
261
|
+
|
|
262
|
+
## Error handling
|
|
263
|
+
|
|
264
|
+
All errors extend `PulseError`:
|
|
265
|
+
|
|
266
|
+
| Class | Thrown when | Notable fields |
|
|
267
|
+
| ---------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------- |
|
|
268
|
+
| `PulseApiError` | API returned a non-retryable error, or retries were exhausted | `status`, `code` (e.g. `network_not_enabled`), `requestId` |
|
|
269
|
+
| `PulseConnectionError` | Request never produced an HTTP response | — |
|
|
270
|
+
| `SignatureVerificationError` | Webhook signature invalid, malformed, or outside the replay window | — |
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
import { PulseApiError } from '@pulsebyshiga/node';
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
await pulse.collectionSessions.create(params);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (error instanceof PulseApiError && error.code === 'network_not_enabled') {
|
|
279
|
+
// asset/network is disabled in your partner configuration
|
|
280
|
+
} else {
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## TypeScript
|
|
287
|
+
|
|
288
|
+
Written in TypeScript; every request, response, webhook event, and error is fully typed and
|
|
289
|
+
exported (`CollectionOrder`, `WebhookEvent`, `PartnerConfig`, `Network`, …). The webhook
|
|
290
|
+
event union discriminates on `type`, so a `switch` narrows `event.data` automatically.
|
|
291
|
+
|
|
292
|
+
## Security model
|
|
293
|
+
|
|
294
|
+
- `sk_*` keys and BVN/NIN are **server-to-server only** — never ship them to a browser or
|
|
295
|
+
mobile app. Clients receive only the short-lived, single-order `session_token` (`cs_*`).
|
|
296
|
+
- Verify every webhook before acting on it; verification is constant-time and replay-bounded.
|
|
297
|
+
- Credit users exclusively from the signed `disbursement.completed` event.
|
|
298
|
+
|
|
299
|
+
## License
|
|
300
|
+
|
|
301
|
+
[MIT](../../LICENSE) © Shiga Digital
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth. One SDK, two ways to authenticate — the caller declares which credential
|
|
3
|
+
* it holds and every feature routes itself:
|
|
4
|
+
*
|
|
5
|
+
* - apiKey : the partner's root secret. Full engine surface. SERVER ONLY —
|
|
6
|
+
* it must never reach a browser bundle (see the guard in client.ts).
|
|
7
|
+
* - session : a short-lived, single-order `cs_*` minted server-side by
|
|
8
|
+
* collectionSessions.create(). Safe to hand to a browser/app.
|
|
9
|
+
*
|
|
10
|
+
* These are the only two credentials that exist: the session token is a
|
|
11
|
+
* scoped-down delegate the API key mints, so a caller never holds both — pick
|
|
12
|
+
* the one for where the code runs. The `use*` factories are the ergonomic entry
|
|
13
|
+
* points; pass their result to `new Pulse(...)`.
|
|
14
|
+
*/
|
|
15
|
+
export type PulseAuth = {
|
|
16
|
+
readonly mode: 'apiKey';
|
|
17
|
+
readonly apiKey: string;
|
|
18
|
+
} | {
|
|
19
|
+
readonly mode: 'session';
|
|
20
|
+
readonly sessionToken: string;
|
|
21
|
+
};
|
|
22
|
+
/** Full engine access with the partner's root key. Server-side only. */
|
|
23
|
+
export declare function useApiKey(apiKey: string): PulseAuth;
|
|
24
|
+
/** Scope every call to one order's session token (`cs_*`). Browser-safe. */
|
|
25
|
+
export declare function useSession(sessionToken: string): PulseAuth;
|
|
26
|
+
/** Normalize the constructor arg: a bare string is treated as an API key. */
|
|
27
|
+
export declare function toAuth(auth: string | PulseAuth): PulseAuth;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Full engine access with the partner's root key. Server-side only. */
|
|
2
|
+
export function useApiKey(apiKey) {
|
|
3
|
+
return { mode: 'apiKey', apiKey };
|
|
4
|
+
}
|
|
5
|
+
/** Scope every call to one order's session token (`cs_*`). Browser-safe. */
|
|
6
|
+
export function useSession(sessionToken) {
|
|
7
|
+
return { mode: 'session', sessionToken };
|
|
8
|
+
}
|
|
9
|
+
/** Normalize the constructor arg: a bare string is treated as an API key. */
|
|
10
|
+
export function toAuth(auth) {
|
|
11
|
+
return typeof auth === 'string' ? { mode: 'apiKey', apiKey: auth } : auth;
|
|
12
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type PulseAuth } from './auth.js';
|
|
2
|
+
export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
|
|
3
|
+
export type HttpClientOptions = {
|
|
4
|
+
/** Override the API host (e.g. a local Pulse or the dev mock). Otherwise derived from the key prefix. */
|
|
5
|
+
baseUrl?: string;
|
|
6
|
+
/** Retries on network errors, 429 and 5xx. Default 2. */
|
|
7
|
+
maxRetries?: number;
|
|
8
|
+
/** Base backoff delay. Default 250ms, doubled per attempt. */
|
|
9
|
+
retryDelayMs?: number;
|
|
10
|
+
fetchImpl?: FetchLike;
|
|
11
|
+
};
|
|
12
|
+
export type QueryValue = string | number | undefined;
|
|
13
|
+
export type RequestParams = {
|
|
14
|
+
method: 'GET' | 'POST';
|
|
15
|
+
path: string;
|
|
16
|
+
query?: Record<string, QueryValue>;
|
|
17
|
+
body?: unknown;
|
|
18
|
+
idempotencyKey?: string;
|
|
19
|
+
};
|
|
20
|
+
export declare class HttpClient {
|
|
21
|
+
private readonly auth;
|
|
22
|
+
private readonly baseUrl;
|
|
23
|
+
private readonly maxRetries;
|
|
24
|
+
private readonly retryDelayMs;
|
|
25
|
+
private readonly fetchImpl;
|
|
26
|
+
constructor(auth: PulseAuth, options?: HttpClientOptions);
|
|
27
|
+
request<T>(params: RequestParams): Promise<T>;
|
|
28
|
+
private authHeaders;
|
|
29
|
+
private buildUrl;
|
|
30
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { PulseAccessError, PulseApiError, PulseConnectionError } from './errors.js';
|
|
3
|
+
import { SDK_VERSION } from './version.js';
|
|
4
|
+
// The live engine host (public Swagger at /swagger/public/index.html).
|
|
5
|
+
// A sandbox host is on the gap list; override baseUrl until one exists.
|
|
6
|
+
const DEFAULT_BASE_URL = 'https://engine-api.shiga.io';
|
|
7
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
8
|
+
const DEFAULT_RETRY_DELAY_MS = 250;
|
|
9
|
+
// Identification only — lets the API measure adoption/error rates per SDK
|
|
10
|
+
// version server-side. The SDK never emits telemetry from partner infra.
|
|
11
|
+
const SDK_IDENTIFIER = `pulse-node/${SDK_VERSION} node/${process.version} (${process.platform})`;
|
|
12
|
+
export class HttpClient {
|
|
13
|
+
auth;
|
|
14
|
+
baseUrl;
|
|
15
|
+
maxRetries;
|
|
16
|
+
retryDelayMs;
|
|
17
|
+
fetchImpl;
|
|
18
|
+
constructor(auth, options = {}) {
|
|
19
|
+
if (auth.mode === 'apiKey' && auth.apiKey.trim() === '') {
|
|
20
|
+
throw new PulseConnectionError('Pulse: apiKey is required');
|
|
21
|
+
}
|
|
22
|
+
if (auth.mode === 'session' && auth.sessionToken.trim() === '') {
|
|
23
|
+
throw new PulseConnectionError('Pulse: sessionToken is required');
|
|
24
|
+
}
|
|
25
|
+
// Never let a root key reach a browser bundle: it is exfiltratable from
|
|
26
|
+
// devtools/source. Sessions (cs_*) are the browser-safe credential.
|
|
27
|
+
if (auth.mode === 'apiKey' && typeof window !== 'undefined') {
|
|
28
|
+
throw new PulseAccessError('Pulse: an API key must never run in a browser. Mint a session server-side ' +
|
|
29
|
+
'(collectionSessions.create) and use useSession(cs_…) on the client.');
|
|
30
|
+
}
|
|
31
|
+
this.auth = auth;
|
|
32
|
+
this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
33
|
+
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
34
|
+
this.retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
35
|
+
this.fetchImpl = options.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
36
|
+
}
|
|
37
|
+
async request(params) {
|
|
38
|
+
const url = this.buildUrl(params.path, params.query);
|
|
39
|
+
const headers = {
|
|
40
|
+
Accept: 'application/json',
|
|
41
|
+
'X-Pulse-SDK': SDK_IDENTIFIER,
|
|
42
|
+
...this.authHeaders(),
|
|
43
|
+
};
|
|
44
|
+
if (params.body !== undefined) {
|
|
45
|
+
headers['Content-Type'] = 'application/json';
|
|
46
|
+
}
|
|
47
|
+
if (params.method === 'POST') {
|
|
48
|
+
// Idempotency on every POST: safe retries even when the caller passes no key.
|
|
49
|
+
headers['Idempotency-Key'] = params.idempotencyKey ?? randomUUID();
|
|
50
|
+
}
|
|
51
|
+
const init = {
|
|
52
|
+
method: params.method,
|
|
53
|
+
headers,
|
|
54
|
+
body: params.body === undefined ? undefined : JSON.stringify(params.body),
|
|
55
|
+
};
|
|
56
|
+
let lastError = new PulseConnectionError(`Request to ${params.path} failed`);
|
|
57
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
|
|
58
|
+
if (attempt > 0) {
|
|
59
|
+
await sleep(this.retryDelayMs * 2 ** (attempt - 1));
|
|
60
|
+
}
|
|
61
|
+
let response;
|
|
62
|
+
try {
|
|
63
|
+
response = await this.fetchImpl(url, init);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
lastError = new PulseConnectionError(`Request to ${params.path} failed: ${describeError(error)}`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (response.status >= 500 || response.status === 429) {
|
|
70
|
+
lastError = await toApiError(response);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw await toApiError(response);
|
|
75
|
+
}
|
|
76
|
+
return (await response.json());
|
|
77
|
+
}
|
|
78
|
+
throw lastError;
|
|
79
|
+
}
|
|
80
|
+
authHeaders() {
|
|
81
|
+
if (this.auth.mode === 'session') {
|
|
82
|
+
return { Authorization: `Bearer ${this.auth.sessionToken}` };
|
|
83
|
+
}
|
|
84
|
+
// apiKey: the engine authenticates with X-API-Key (Swagger ApiKeyAuth); the
|
|
85
|
+
// product surface was specified with a Bearer. One key, both schemes, until
|
|
86
|
+
// the engine settles on one (see docs/engine-gap.md).
|
|
87
|
+
return { 'X-API-Key': this.auth.apiKey, Authorization: `Bearer ${this.auth.apiKey}` };
|
|
88
|
+
}
|
|
89
|
+
buildUrl(path, query) {
|
|
90
|
+
const url = new URL(path, this.baseUrl);
|
|
91
|
+
if (query !== undefined) {
|
|
92
|
+
for (const [key, value] of Object.entries(query)) {
|
|
93
|
+
if (value !== undefined) {
|
|
94
|
+
url.searchParams.set(key, String(value));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return url.toString();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function toApiError(response) {
|
|
102
|
+
let code = 'unknown_error';
|
|
103
|
+
let message = `HTTP ${response.status}`;
|
|
104
|
+
try {
|
|
105
|
+
const body = (await response.json());
|
|
106
|
+
if (typeof body.error?.code === 'string')
|
|
107
|
+
code = body.error.code;
|
|
108
|
+
if (typeof body.error?.message === 'string')
|
|
109
|
+
message = body.error.message;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// Non-JSON error body — keep the defaults.
|
|
113
|
+
}
|
|
114
|
+
return new PulseApiError(response.status, code, message, response.headers.get('request-id'));
|
|
115
|
+
}
|
|
116
|
+
function sleep(ms) {
|
|
117
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
118
|
+
}
|
|
119
|
+
function describeError(error) {
|
|
120
|
+
return error instanceof Error ? error.message : String(error);
|
|
121
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare class PulseError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export declare class PulseApiError extends PulseError {
|
|
5
|
+
readonly status: number;
|
|
6
|
+
readonly code: string;
|
|
7
|
+
readonly requestId: string | null;
|
|
8
|
+
constructor(status: number, code: string, message: string, requestId: string | null);
|
|
9
|
+
}
|
|
10
|
+
export declare class PulseConnectionError extends PulseError {
|
|
11
|
+
}
|
|
12
|
+
export declare class SignatureVerificationError extends PulseError {
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A credential was used where it isn't allowed — e.g. an API key constructed in
|
|
16
|
+
* a browser, or a feature invoked without the credential it needs.
|
|
17
|
+
*/
|
|
18
|
+
export declare class PulseAccessError extends PulseError {
|
|
19
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export class PulseError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = new.target.name;
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export class PulseApiError extends PulseError {
|
|
8
|
+
status;
|
|
9
|
+
code;
|
|
10
|
+
requestId;
|
|
11
|
+
constructor(status, code, message, requestId) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.requestId = requestId;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class PulseConnectionError extends PulseError {
|
|
19
|
+
}
|
|
20
|
+
export class SignatureVerificationError extends PulseError {
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A credential was used where it isn't allowed — e.g. an API key constructed in
|
|
24
|
+
* a browser, or a feature invoked without the credential it needs.
|
|
25
|
+
*/
|
|
26
|
+
export class PulseAccessError extends PulseError {
|
|
27
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type PulseAuth } from './auth.js';
|
|
2
|
+
import { type HttpClientOptions } from './client.js';
|
|
3
|
+
import { Banks } from './resources/banks.js';
|
|
4
|
+
import { CollectionOrders } from './resources/collectionOrders.js';
|
|
5
|
+
import { CollectionSessions } from './resources/collectionSessions.js';
|
|
6
|
+
import { Offramp } from './resources/offramp.js';
|
|
7
|
+
import { Onramp } from './resources/onramp.js';
|
|
8
|
+
import { Quotes } from './resources/quotes.js';
|
|
9
|
+
import { Rates } from './resources/rates.js';
|
|
10
|
+
import { Webhooks, type ProcessedEventStore } from './webhooks.js';
|
|
11
|
+
export type PulseOptions = HttpClientOptions & {
|
|
12
|
+
/** Default secret for webhooks.verify(); can also be passed per call. */
|
|
13
|
+
webhookSecret?: string;
|
|
14
|
+
/** Backing store for verifyOnce() de-duplication. Defaults to in-memory (single process). */
|
|
15
|
+
webhookEventStore?: ProcessedEventStore;
|
|
16
|
+
};
|
|
17
|
+
export declare class Pulse {
|
|
18
|
+
readonly quotes: Quotes;
|
|
19
|
+
readonly rates: Rates;
|
|
20
|
+
readonly offramp: Offramp;
|
|
21
|
+
readonly onramp: Onramp;
|
|
22
|
+
readonly banks: Banks;
|
|
23
|
+
readonly collectionSessions: CollectionSessions;
|
|
24
|
+
readonly collectionOrders: CollectionOrders;
|
|
25
|
+
readonly webhooks: Webhooks;
|
|
26
|
+
/**
|
|
27
|
+
* @param auth Either a bare API-key string (treated as `useApiKey`), or an
|
|
28
|
+
* auth descriptor from `useApiKey` / `useSession`.
|
|
29
|
+
*/
|
|
30
|
+
constructor(auth: string | PulseAuth, options?: PulseOptions);
|
|
31
|
+
}
|
|
32
|
+
export default Pulse;
|
|
33
|
+
export * from './types.js';
|
|
34
|
+
export * from './errors.js';
|
|
35
|
+
export { useApiKey, useSession, toAuth, type PulseAuth } from './auth.js';
|
|
36
|
+
export { Rates, type RatePreview, type RatePreviewParams } from './resources/rates.js';
|
|
37
|
+
export { InMemoryProcessedEventStore, Webhooks, signPayload, type ProcessedEventStore, type VerifyOptions, } from './webhooks.js';
|
|
38
|
+
export type { FetchLike, HttpClientOptions } from './client.js';
|
|
39
|
+
export type { CreateSessionOptions } from './resources/collectionSessions.js';
|
|
40
|
+
export { SDK_VERSION } from './version.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { toAuth } from './auth.js';
|
|
2
|
+
import { HttpClient } from './client.js';
|
|
3
|
+
import { Banks } from './resources/banks.js';
|
|
4
|
+
import { CollectionOrders } from './resources/collectionOrders.js';
|
|
5
|
+
import { CollectionSessions } from './resources/collectionSessions.js';
|
|
6
|
+
import { Offramp } from './resources/offramp.js';
|
|
7
|
+
import { Onramp } from './resources/onramp.js';
|
|
8
|
+
import { Quotes } from './resources/quotes.js';
|
|
9
|
+
import { Rates } from './resources/rates.js';
|
|
10
|
+
import { Webhooks } from './webhooks.js';
|
|
11
|
+
export class Pulse {
|
|
12
|
+
// Engine surface — live contract (quote-first).
|
|
13
|
+
quotes;
|
|
14
|
+
rates;
|
|
15
|
+
offramp;
|
|
16
|
+
onramp;
|
|
17
|
+
banks;
|
|
18
|
+
// Product surface — collection sessions + webhooks (see docs/engine-gap.md).
|
|
19
|
+
collectionSessions;
|
|
20
|
+
collectionOrders;
|
|
21
|
+
webhooks;
|
|
22
|
+
/**
|
|
23
|
+
* @param auth Either a bare API-key string (treated as `useApiKey`), or an
|
|
24
|
+
* auth descriptor from `useApiKey` / `useSession`.
|
|
25
|
+
*/
|
|
26
|
+
constructor(auth, options = {}) {
|
|
27
|
+
const { webhookSecret, webhookEventStore, ...httpOptions } = options;
|
|
28
|
+
const http = new HttpClient(toAuth(auth), httpOptions);
|
|
29
|
+
this.quotes = new Quotes(http);
|
|
30
|
+
this.rates = new Rates(http);
|
|
31
|
+
this.offramp = new Offramp(http);
|
|
32
|
+
this.onramp = new Onramp(http);
|
|
33
|
+
this.banks = new Banks(http);
|
|
34
|
+
this.collectionSessions = new CollectionSessions(http);
|
|
35
|
+
this.collectionOrders = new CollectionOrders(http);
|
|
36
|
+
this.webhooks = new Webhooks(webhookSecret, webhookEventStore);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export default Pulse;
|
|
40
|
+
export * from './types.js';
|
|
41
|
+
export * from './errors.js';
|
|
42
|
+
export { useApiKey, useSession, toAuth } from './auth.js';
|
|
43
|
+
export { Rates } from './resources/rates.js';
|
|
44
|
+
export { InMemoryProcessedEventStore, Webhooks, signPayload, } from './webhooks.js';
|
|
45
|
+
export { SDK_VERSION } from './version.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { HttpClient } from '../client.js';
|
|
2
|
+
import type { Bank, ResolveAccountParams, VerifiedAccount } from '../types.js';
|
|
3
|
+
export declare class Banks {
|
|
4
|
+
private readonly http;
|
|
5
|
+
constructor(http: HttpClient);
|
|
6
|
+
/** Supported banks (name + NIP code) for offramp payout targets. */
|
|
7
|
+
list(): Promise<Bank[]>;
|
|
8
|
+
/** Name enquiry — verify an account before creating an offramp order against it. */
|
|
9
|
+
resolve(params: ResolveAccountParams): Promise<VerifiedAccount>;
|
|
10
|
+
}
|