@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/dist/types.d.ts
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types for the Pulse API. Two sections:
|
|
3
|
+
*
|
|
4
|
+
* 1. ENGINE API — reconciled 1:1 against the live public contract
|
|
5
|
+
* (https://engine-api.shiga.io/swagger/public/index.html). These are real.
|
|
6
|
+
* 2. PROPOSED product extension — the collection-session/client-session/webhook
|
|
7
|
+
* surface the embedded product needs; not yet in the engine (see
|
|
8
|
+
* docs/engine-gap.md).
|
|
9
|
+
*
|
|
10
|
+
* Wire convention (both): snake_case JSON, decimal amounts as strings.
|
|
11
|
+
*/
|
|
12
|
+
/** Networks the engine supports today (uppercase on the wire). Non-EVM support is on the gap list. */
|
|
13
|
+
export type EngineNetwork = 'BASE' | 'POLYGON' | 'ARBITRUM' | 'OPTIMISM' | 'ETHEREUM' | 'BSC';
|
|
14
|
+
export type CreateQuoteParams = {
|
|
15
|
+
/** Decimal string in the source currency, e.g. "100.50". */
|
|
16
|
+
amount: string;
|
|
17
|
+
source_currency: string;
|
|
18
|
+
destination_currency: string;
|
|
19
|
+
network?: EngineNetwork;
|
|
20
|
+
};
|
|
21
|
+
/** A locked price. Orders are created FROM a quote (`quote_id`) before it expires. */
|
|
22
|
+
export type Quote = {
|
|
23
|
+
id: string;
|
|
24
|
+
rate: string;
|
|
25
|
+
source_amount: string;
|
|
26
|
+
destination_amount: string;
|
|
27
|
+
expires_at: string;
|
|
28
|
+
};
|
|
29
|
+
export type CreateOfframpOrderParams = {
|
|
30
|
+
quote_id: string;
|
|
31
|
+
/** Partner's stable reference for the end user. */
|
|
32
|
+
customer_id: string;
|
|
33
|
+
customer_name: string;
|
|
34
|
+
customer_email: string;
|
|
35
|
+
/** Payout target — resolve via banks.resolve() (name enquiry) first. */
|
|
36
|
+
account_number: string;
|
|
37
|
+
account_name: string;
|
|
38
|
+
bank_code: string;
|
|
39
|
+
bank_name: string;
|
|
40
|
+
};
|
|
41
|
+
/** Crypto → fiat. The user sends stablecoin to `deposit_address`; fiat lands in the bank account. */
|
|
42
|
+
export type OfframpOrder = {
|
|
43
|
+
id: string;
|
|
44
|
+
customer_id: string;
|
|
45
|
+
deposit_address: string;
|
|
46
|
+
/**
|
|
47
|
+
* Proposed: one deposit address per chain family so a single order serves every
|
|
48
|
+
* supported chain (one EVM address for all EVM chains, plus Solana, TON, …).
|
|
49
|
+
* Keyed by family or network name. Absent on today's single-address engine —
|
|
50
|
+
* `deposit_address` is the fallback. See docs/engine-gap.md.
|
|
51
|
+
*/
|
|
52
|
+
deposit_addresses?: Record<string, string>;
|
|
53
|
+
source_amount: string;
|
|
54
|
+
source_currency: string;
|
|
55
|
+
destination_amount: string;
|
|
56
|
+
destination_currency: string;
|
|
57
|
+
quoted_rate: string;
|
|
58
|
+
/** Lifecycle value — the engine does not document the enum yet (gap list). */
|
|
59
|
+
status: string;
|
|
60
|
+
reference: string;
|
|
61
|
+
flow_type: string;
|
|
62
|
+
execution_mode: string;
|
|
63
|
+
metadata: Record<string, unknown>;
|
|
64
|
+
created_at: string;
|
|
65
|
+
updated_at: string;
|
|
66
|
+
};
|
|
67
|
+
export type CreateOnrampOrderParams = {
|
|
68
|
+
quote_id: string;
|
|
69
|
+
customer_id: string;
|
|
70
|
+
customer_name: string;
|
|
71
|
+
customer_email: string;
|
|
72
|
+
/** Wallet that receives the crypto once the fiat lands. */
|
|
73
|
+
destination_address: string;
|
|
74
|
+
destination_tag?: string;
|
|
75
|
+
};
|
|
76
|
+
/** Virtual bank account the end user pays into for an onramp order. */
|
|
77
|
+
export type OnrampVirtualAccount = {
|
|
78
|
+
id: string;
|
|
79
|
+
account_name: string;
|
|
80
|
+
account_number: string;
|
|
81
|
+
bank_code: string;
|
|
82
|
+
bank_name: string;
|
|
83
|
+
amount: string;
|
|
84
|
+
provider: string;
|
|
85
|
+
provider_reference: string;
|
|
86
|
+
reference: string;
|
|
87
|
+
status: string;
|
|
88
|
+
type: string;
|
|
89
|
+
expires_at: string;
|
|
90
|
+
created_at: string;
|
|
91
|
+
updated_at: string;
|
|
92
|
+
};
|
|
93
|
+
/** Fiat → crypto. The user pays `account`; crypto is sent to `destination_address`. */
|
|
94
|
+
export type OnrampOrder = {
|
|
95
|
+
id: string;
|
|
96
|
+
customer_id: string;
|
|
97
|
+
account: OnrampVirtualAccount;
|
|
98
|
+
destination_address: string;
|
|
99
|
+
source_amount: string;
|
|
100
|
+
source_currency: string;
|
|
101
|
+
destination_amount: string;
|
|
102
|
+
destination_currency: string;
|
|
103
|
+
quoted_rate: string;
|
|
104
|
+
/** Lifecycle value — the engine does not document the enum yet (gap list). */
|
|
105
|
+
status: string;
|
|
106
|
+
reference: string;
|
|
107
|
+
flow_type: string;
|
|
108
|
+
execution_mode: string;
|
|
109
|
+
metadata: Record<string, unknown>;
|
|
110
|
+
created_at: string;
|
|
111
|
+
updated_at: string;
|
|
112
|
+
};
|
|
113
|
+
export type Bank = {
|
|
114
|
+
/** NIP bank code. */
|
|
115
|
+
code: string;
|
|
116
|
+
name: string;
|
|
117
|
+
};
|
|
118
|
+
export type ResolveAccountParams = {
|
|
119
|
+
account_number: string;
|
|
120
|
+
bank_code: string;
|
|
121
|
+
};
|
|
122
|
+
export type VerifiedAccount = {
|
|
123
|
+
account_name: string;
|
|
124
|
+
account_number: string;
|
|
125
|
+
bank_code: string;
|
|
126
|
+
bank_name: string;
|
|
127
|
+
};
|
|
128
|
+
export type Gate = 'naira' | 'usd';
|
|
129
|
+
/**
|
|
130
|
+
* Which way value flows. `offramp` = crypto in → fiat out (the collection
|
|
131
|
+
* product: user sends stablecoin, fiat settles to a bank account). `onramp` =
|
|
132
|
+
* fiat in → crypto out (user pays a virtual bank account, crypto is sent to a
|
|
133
|
+
* wallet). Defaults to `offramp` when a session omits it.
|
|
134
|
+
*/
|
|
135
|
+
export type CollectionDirection = 'offramp' | 'onramp';
|
|
136
|
+
export type Asset = 'USDC' | 'USDT';
|
|
137
|
+
/**
|
|
138
|
+
* Full network set per the approved design (business requirement). The
|
|
139
|
+
* per-partner ENABLED subset comes from Pulse config (R5.1) once it exists.
|
|
140
|
+
*/
|
|
141
|
+
export type Network = 'ethereum' | 'base' | 'polygon' | 'arbitrum' | 'optimism' | 'celo' | 'plasma' | 'solana' | 'ton';
|
|
142
|
+
export type FiatCurrency = 'NGN' | 'USD';
|
|
143
|
+
export type TargetAmount = {
|
|
144
|
+
currency: FiatCurrency;
|
|
145
|
+
amount: string;
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Crypto side of a session, as an alternative to `target`: the amount of `asset`
|
|
149
|
+
* the user SENDS (offramp). The fiat target is derived from it. Exactly one of
|
|
150
|
+
* `target` / `source` is provided.
|
|
151
|
+
*/
|
|
152
|
+
export type SourceAmount = {
|
|
153
|
+
asset: Asset;
|
|
154
|
+
amount: string;
|
|
155
|
+
};
|
|
156
|
+
export type NairaAccountProvision = {
|
|
157
|
+
provision: true;
|
|
158
|
+
/** Sensitive — server-to-server only; never reaches the hosted flow. */
|
|
159
|
+
bvn: string;
|
|
160
|
+
/** Sensitive — server-to-server only; never reaches the hosted flow. */
|
|
161
|
+
nin: string;
|
|
162
|
+
};
|
|
163
|
+
export type NairaAccountReference = {
|
|
164
|
+
provision: false;
|
|
165
|
+
virtual_account_ref: string;
|
|
166
|
+
};
|
|
167
|
+
export type NairaAccount = NairaAccountProvision | NairaAccountReference;
|
|
168
|
+
export type CreateNairaSessionParams = {
|
|
169
|
+
gate: 'naira';
|
|
170
|
+
/** Value direction; defaults to `offramp` (crypto in → fiat out). */
|
|
171
|
+
direction?: CollectionDirection;
|
|
172
|
+
/** Fiat side — provide this OR `source` (the crypto side). */
|
|
173
|
+
target?: TargetAmount;
|
|
174
|
+
/** Crypto side (offramp) — the amount of `asset` to send; fiat is derived. */
|
|
175
|
+
source?: SourceAmount;
|
|
176
|
+
asset: Asset;
|
|
177
|
+
network: Network;
|
|
178
|
+
/** Partner's stable reference for the end user. */
|
|
179
|
+
user_ref: string;
|
|
180
|
+
account: NairaAccount;
|
|
181
|
+
partner_ref?: string;
|
|
182
|
+
};
|
|
183
|
+
export type CreateUsdSessionParams = {
|
|
184
|
+
gate: 'usd';
|
|
185
|
+
/** Value direction; defaults to `offramp` (crypto in → fiat out). */
|
|
186
|
+
direction?: CollectionDirection;
|
|
187
|
+
/** Fiat side — provide this OR `source` (the crypto side). */
|
|
188
|
+
target?: TargetAmount;
|
|
189
|
+
/** Crypto side (offramp) — the amount of `asset` to send; fiat is derived. */
|
|
190
|
+
source?: SourceAmount;
|
|
191
|
+
asset: Asset;
|
|
192
|
+
network: Network;
|
|
193
|
+
partner_ref?: string;
|
|
194
|
+
};
|
|
195
|
+
export type CreateCollectionSessionParams = CreateNairaSessionParams | CreateUsdSessionParams;
|
|
196
|
+
export type SessionQuote = {
|
|
197
|
+
/** Fiat per 1 asset unit, decimal string. */
|
|
198
|
+
rate: string;
|
|
199
|
+
asset: Asset;
|
|
200
|
+
/** Exact stablecoin amount the user must send. */
|
|
201
|
+
asset_amount: string;
|
|
202
|
+
locked_until: string;
|
|
203
|
+
};
|
|
204
|
+
export type OrderStatus = 'awaiting_payment' | 'deposit_detected' | 'deposit_confirmed' | 'converting' | 'converted_unsettled' | 'completed' | 'expired' | 'failed';
|
|
205
|
+
export type AmountStatus = 'pending' | 'exact' | 'underpaid' | 'overpaid';
|
|
206
|
+
export type Deposit = {
|
|
207
|
+
tx_hash: string;
|
|
208
|
+
/** On-chain detected amount — always the credited figure, never the quoted one. */
|
|
209
|
+
amount: string;
|
|
210
|
+
asset: Asset;
|
|
211
|
+
network: Network;
|
|
212
|
+
detected_at: string;
|
|
213
|
+
confirmed_at: string | null;
|
|
214
|
+
};
|
|
215
|
+
/**
|
|
216
|
+
* Funds detected on a network other than the order's. PROPOSED contract shape
|
|
217
|
+
* (PRD §10 wrong-chain row) — the real detection signal comes from Pulse;
|
|
218
|
+
* recovery policy is a [DECIDE] item.
|
|
219
|
+
*/
|
|
220
|
+
export type WrongChainDeposit = {
|
|
221
|
+
tx_hash: string;
|
|
222
|
+
amount: string;
|
|
223
|
+
asset: Asset;
|
|
224
|
+
/** Network the funds actually arrived on (may be outside the supported set). */
|
|
225
|
+
network: string;
|
|
226
|
+
detected_at: string;
|
|
227
|
+
};
|
|
228
|
+
export type Disbursement = {
|
|
229
|
+
settlement_ref: string;
|
|
230
|
+
currency: FiatCurrency;
|
|
231
|
+
amount: string;
|
|
232
|
+
/** Naira gate only; null for USD (partner omnibus). */
|
|
233
|
+
virtual_account_ref: string | null;
|
|
234
|
+
completed_at: string;
|
|
235
|
+
};
|
|
236
|
+
/**
|
|
237
|
+
* Onramp only: the virtual bank account the end user transfers fiat into.
|
|
238
|
+
* Mirrors the engine's onramp `account` (see docs/engine-gap.md). Null on
|
|
239
|
+
* offramp orders, where the user sends crypto to `deposit_address` instead.
|
|
240
|
+
*/
|
|
241
|
+
export type PayAccount = {
|
|
242
|
+
bank_name: string;
|
|
243
|
+
bank_code: string;
|
|
244
|
+
/** Optional bank logo (progressive enhancement; hidden if it fails to load). */
|
|
245
|
+
bank_logo_url?: string;
|
|
246
|
+
account_number: string;
|
|
247
|
+
account_name: string;
|
|
248
|
+
/** Fiat to transfer (equals target.amount), decimal string. */
|
|
249
|
+
amount: string;
|
|
250
|
+
expires_at: string;
|
|
251
|
+
};
|
|
252
|
+
export type CollectionSession = {
|
|
253
|
+
order_id: string;
|
|
254
|
+
/** Short-lived single-order token for the hosted flow. Returned once. */
|
|
255
|
+
session_token: string;
|
|
256
|
+
/** Unique per order — the attribution key. */
|
|
257
|
+
deposit_address: string;
|
|
258
|
+
/** Solana Pay URI (solana) or EIP-681 URI (EVM). */
|
|
259
|
+
qr_payload: string;
|
|
260
|
+
quote: SessionQuote;
|
|
261
|
+
status: OrderStatus;
|
|
262
|
+
created_at: string;
|
|
263
|
+
};
|
|
264
|
+
export type CollectionOrder = {
|
|
265
|
+
id: string;
|
|
266
|
+
gate: Gate;
|
|
267
|
+
direction: CollectionDirection;
|
|
268
|
+
status: OrderStatus;
|
|
269
|
+
amount_status: AmountStatus;
|
|
270
|
+
target: TargetAmount;
|
|
271
|
+
asset: Asset;
|
|
272
|
+
network: Network;
|
|
273
|
+
user_ref: string | null;
|
|
274
|
+
partner_ref: string | null;
|
|
275
|
+
/** Offramp: where the user sends crypto. Onramp: unused (see pay_account). */
|
|
276
|
+
deposit_address: string;
|
|
277
|
+
/**
|
|
278
|
+
* Offramp: deposit address per chain family/network when the engine returns
|
|
279
|
+
* them, so switching chains reuses the same order instead of creating a new
|
|
280
|
+
* one. Falls back to `deposit_address`. See `depositAddressFor`.
|
|
281
|
+
*/
|
|
282
|
+
deposit_addresses?: Record<string, string>;
|
|
283
|
+
qr_payload: string;
|
|
284
|
+
/** Onramp: the virtual bank account to pay into. Null on offramp. */
|
|
285
|
+
pay_account: PayAccount | null;
|
|
286
|
+
quote: SessionQuote;
|
|
287
|
+
deposit: Deposit | null;
|
|
288
|
+
wrong_chain_deposit: WrongChainDeposit | null;
|
|
289
|
+
disbursement: Disbursement | null;
|
|
290
|
+
created_at: string;
|
|
291
|
+
};
|
|
292
|
+
/** Theme tokens as stored against the partner record (wire snake_case). */
|
|
293
|
+
export type PartnerConfigTheme = {
|
|
294
|
+
primary_color?: string;
|
|
295
|
+
border_radius?: string;
|
|
296
|
+
mode?: 'light' | 'dark';
|
|
297
|
+
font_family?: string;
|
|
298
|
+
ink_color?: string;
|
|
299
|
+
muted_color?: string;
|
|
300
|
+
background_color?: string;
|
|
301
|
+
button_text_color?: string;
|
|
302
|
+
line_color?: string;
|
|
303
|
+
success_color?: string;
|
|
304
|
+
danger_color?: string;
|
|
305
|
+
page_background_color?: string;
|
|
306
|
+
density?: 'comfortable' | 'compact';
|
|
307
|
+
};
|
|
308
|
+
/** Copy overrides as stored against the partner record (wire snake_case). */
|
|
309
|
+
export type PartnerConfigStrings = {
|
|
310
|
+
title?: string;
|
|
311
|
+
subtitle?: string;
|
|
312
|
+
hero_label?: string;
|
|
313
|
+
success_title?: string;
|
|
314
|
+
success_subtitle?: string;
|
|
315
|
+
expired_title?: string;
|
|
316
|
+
expired_subtitle?: string;
|
|
317
|
+
refresh_button?: string;
|
|
318
|
+
copy_button?: string;
|
|
319
|
+
copied_button?: string;
|
|
320
|
+
};
|
|
321
|
+
/**
|
|
322
|
+
* PROPOSED contract shape (R5 per-partner config). Set on the partner's
|
|
323
|
+
* dashboard, stored against the partner record in Pulse, resolved via the API
|
|
324
|
+
* key at session creation, and carried on client-session responses so the
|
|
325
|
+
* hosted flow reflects it with zero partner front-end code.
|
|
326
|
+
*
|
|
327
|
+
* Semantics: absent/null config = full defaults — every network and asset is
|
|
328
|
+
* available. `enabled_networks`/`enabled_assets` only ever NARROW the full
|
|
329
|
+
* built-in set, never widen it; mount options may narrow further but cannot
|
|
330
|
+
* re-enable what the config removed. Pulse enforces the lists server-side
|
|
331
|
+
* (session creation and re-selection reject disabled values) — the embed
|
|
332
|
+
* merely mirrors them in the UI.
|
|
333
|
+
*/
|
|
334
|
+
export type PartnerConfig = {
|
|
335
|
+
/** Subset of the full network set this partner accepts. Absent = all. */
|
|
336
|
+
enabled_networks?: Network[];
|
|
337
|
+
/** Subset of the asset set this partner accepts. Absent = all. */
|
|
338
|
+
enabled_assets?: Asset[];
|
|
339
|
+
theme?: PartnerConfigTheme;
|
|
340
|
+
strings?: PartnerConfigStrings;
|
|
341
|
+
/** "none" removes the attribution footer (R3.4 — never mandatory). */
|
|
342
|
+
attribution?: 'subtle' | 'none';
|
|
343
|
+
/** Default flow when the mount doesn't pin one: selection UI or direct pay. */
|
|
344
|
+
flow?: 'select' | 'direct';
|
|
345
|
+
};
|
|
346
|
+
/**
|
|
347
|
+
* GET /v1/client/session response — the order plus the resolved partner
|
|
348
|
+
* config (also echoed on refresh_quote/select for symmetry). `partner_config`
|
|
349
|
+
* missing or null means the partner has no dashboard config.
|
|
350
|
+
*/
|
|
351
|
+
export type ClientSessionState = CollectionOrder & {
|
|
352
|
+
partner_config?: PartnerConfig | null;
|
|
353
|
+
};
|
|
354
|
+
/**
|
|
355
|
+
* PROPOSED contract: `POST /v1/client/telemetry` (session-token auth, body
|
|
356
|
+
* `{ events: ClientTelemetryEvent[] }`, responds 202). Batched, pseudonymous
|
|
357
|
+
* funnel/error events emitted by the hosted embed — session/order ids are the
|
|
358
|
+
* only correlation keys; no PII, and delivery is fire-and-forget (the payment
|
|
359
|
+
* flow never depends on it). See docs/observability.md.
|
|
360
|
+
*/
|
|
361
|
+
export type ClientTelemetryEvent = {
|
|
362
|
+
/** e.g. "session_loaded", "step_viewed", "status_change", "client_error". */
|
|
363
|
+
name: string;
|
|
364
|
+
/** ISO timestamp at emission. */
|
|
365
|
+
at: string;
|
|
366
|
+
/** Embed build that emitted the event, e.g. "embed/0.1.0". */
|
|
367
|
+
source: string;
|
|
368
|
+
/** Small scalar payload (step, status, error code…) — never free-form PII. */
|
|
369
|
+
data?: Record<string, string | number | boolean | null>;
|
|
370
|
+
};
|
|
371
|
+
export type ListOrdersParams = {
|
|
372
|
+
gate?: Gate;
|
|
373
|
+
status?: OrderStatus;
|
|
374
|
+
user_ref?: string;
|
|
375
|
+
created_after?: string;
|
|
376
|
+
created_before?: string;
|
|
377
|
+
limit?: number;
|
|
378
|
+
cursor?: string;
|
|
379
|
+
};
|
|
380
|
+
export type Page<T> = {
|
|
381
|
+
data: T[];
|
|
382
|
+
next_cursor: string | null;
|
|
383
|
+
};
|
|
384
|
+
export type AmountMismatchData = {
|
|
385
|
+
order_id: string;
|
|
386
|
+
expected: string;
|
|
387
|
+
received: string;
|
|
388
|
+
asset: Asset;
|
|
389
|
+
network: Network;
|
|
390
|
+
tx_hash: string;
|
|
391
|
+
};
|
|
392
|
+
type WebhookEventBase = {
|
|
393
|
+
/** evt_* — the idempotency key for consumers. */
|
|
394
|
+
id: string;
|
|
395
|
+
created_at: string;
|
|
396
|
+
};
|
|
397
|
+
export type WebhookEvent = (WebhookEventBase & {
|
|
398
|
+
type: 'collection.order.created';
|
|
399
|
+
data: {
|
|
400
|
+
order: CollectionOrder;
|
|
401
|
+
};
|
|
402
|
+
}) | (WebhookEventBase & {
|
|
403
|
+
type: 'collection.deposit.detected';
|
|
404
|
+
data: {
|
|
405
|
+
order_id: string;
|
|
406
|
+
deposit: Deposit;
|
|
407
|
+
};
|
|
408
|
+
}) | (WebhookEventBase & {
|
|
409
|
+
type: 'collection.deposit.confirmed';
|
|
410
|
+
data: {
|
|
411
|
+
order_id: string;
|
|
412
|
+
deposit: Deposit;
|
|
413
|
+
};
|
|
414
|
+
}) | (WebhookEventBase & {
|
|
415
|
+
type: 'collection.deposit.wrong_chain';
|
|
416
|
+
data: {
|
|
417
|
+
order_id: string;
|
|
418
|
+
deposit: WrongChainDeposit;
|
|
419
|
+
expected_network: Network;
|
|
420
|
+
};
|
|
421
|
+
}) | (WebhookEventBase & {
|
|
422
|
+
type: 'collection.amount.underpaid';
|
|
423
|
+
data: AmountMismatchData;
|
|
424
|
+
}) | (WebhookEventBase & {
|
|
425
|
+
type: 'collection.amount.overpaid';
|
|
426
|
+
data: AmountMismatchData;
|
|
427
|
+
}) | (WebhookEventBase & {
|
|
428
|
+
type: 'disbursement.completed';
|
|
429
|
+
data: {
|
|
430
|
+
order_id: string;
|
|
431
|
+
disbursement: Disbursement;
|
|
432
|
+
};
|
|
433
|
+
}) | (WebhookEventBase & {
|
|
434
|
+
type: 'collection.order.expired';
|
|
435
|
+
data: {
|
|
436
|
+
order_id: string;
|
|
437
|
+
};
|
|
438
|
+
}) | (WebhookEventBase & {
|
|
439
|
+
type: 'collection.order.failed';
|
|
440
|
+
data: {
|
|
441
|
+
order_id: string;
|
|
442
|
+
reason: string;
|
|
443
|
+
};
|
|
444
|
+
});
|
|
445
|
+
export type WebhookEventType = WebhookEvent['type'];
|
|
446
|
+
export {};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types for the Pulse API. Two sections:
|
|
3
|
+
*
|
|
4
|
+
* 1. ENGINE API — reconciled 1:1 against the live public contract
|
|
5
|
+
* (https://engine-api.shiga.io/swagger/public/index.html). These are real.
|
|
6
|
+
* 2. PROPOSED product extension — the collection-session/client-session/webhook
|
|
7
|
+
* surface the embedded product needs; not yet in the engine (see
|
|
8
|
+
* docs/engine-gap.md).
|
|
9
|
+
*
|
|
10
|
+
* Wire convention (both): snake_case JSON, decimal amounts as strings.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { WebhookEvent } from './types.js';
|
|
2
|
+
export type VerifyOptions = {
|
|
3
|
+
/** Max allowed clock skew between the signature timestamp and now. Default 300s. */
|
|
4
|
+
toleranceSeconds?: number;
|
|
5
|
+
/** Unix-seconds clock, injectable for tests. */
|
|
6
|
+
now?: () => number;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Produce a "Pulse-Signature" header value for a payload:
|
|
10
|
+
* `t=<unix>,v1=<hex hmac-sha256 of "<unix>.<payload>">`.
|
|
11
|
+
* Exported for test fixtures and the local dev mock.
|
|
12
|
+
*/
|
|
13
|
+
export declare function signPayload(payload: string, secret: string, timestamp: number): string;
|
|
14
|
+
export type ProcessedEventStore = {
|
|
15
|
+
/** True if this event id has already been processed. */
|
|
16
|
+
has(eventId: string): boolean | Promise<boolean>;
|
|
17
|
+
/** Record an event id as processed. */
|
|
18
|
+
add(eventId: string): void | Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Single-process default. In multi-instance deployments back verifyOnce with
|
|
22
|
+
* a shared store (Redis, DB) via the ProcessedEventStore contract instead.
|
|
23
|
+
*/
|
|
24
|
+
export declare class InMemoryProcessedEventStore {
|
|
25
|
+
private readonly ttlMs;
|
|
26
|
+
private readonly seen;
|
|
27
|
+
constructor(ttlMs?: number);
|
|
28
|
+
has(eventId: string): boolean;
|
|
29
|
+
add(eventId: string): void;
|
|
30
|
+
}
|
|
31
|
+
export declare class Webhooks {
|
|
32
|
+
private readonly secret;
|
|
33
|
+
private readonly store;
|
|
34
|
+
constructor(secret?: string, store?: ProcessedEventStore);
|
|
35
|
+
/**
|
|
36
|
+
* Verify a webhook delivery against its "Pulse-Signature" header and return
|
|
37
|
+
* the typed event. `payload` MUST be the raw request body — a re-serialized
|
|
38
|
+
* JSON object will not match the signature.
|
|
39
|
+
* Throws SignatureVerificationError on any mismatch.
|
|
40
|
+
*/
|
|
41
|
+
verify(payload: string | Buffer, signatureHeader: string, secret?: string, options?: VerifyOptions): WebhookEvent;
|
|
42
|
+
/**
|
|
43
|
+
* Verify a delivery AND de-duplicate on event id in one call. Delivery is
|
|
44
|
+
* at-least-once, so redeliveries WILL arrive: the first sight of an id
|
|
45
|
+
* returns the typed event, a duplicate returns null (skip it, respond 200).
|
|
46
|
+
* Signature failures still throw before the store is touched.
|
|
47
|
+
*/
|
|
48
|
+
verifyOnce(payload: string | Buffer, signatureHeader: string, secret?: string, options?: VerifyOptions): Promise<WebhookEvent | null>;
|
|
49
|
+
}
|
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { SignatureVerificationError } from './errors.js';
|
|
3
|
+
const DEFAULT_TOLERANCE_SECONDS = 300;
|
|
4
|
+
/**
|
|
5
|
+
* Produce a "Pulse-Signature" header value for a payload:
|
|
6
|
+
* `t=<unix>,v1=<hex hmac-sha256 of "<unix>.<payload>">`.
|
|
7
|
+
* Exported for test fixtures and the local dev mock.
|
|
8
|
+
*/
|
|
9
|
+
export function signPayload(payload, secret, timestamp) {
|
|
10
|
+
const mac = createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');
|
|
11
|
+
return `t=${timestamp},v1=${mac}`;
|
|
12
|
+
}
|
|
13
|
+
const DEFAULT_DEDUPE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
/**
|
|
15
|
+
* Single-process default. In multi-instance deployments back verifyOnce with
|
|
16
|
+
* a shared store (Redis, DB) via the ProcessedEventStore contract instead.
|
|
17
|
+
*/
|
|
18
|
+
export class InMemoryProcessedEventStore {
|
|
19
|
+
ttlMs;
|
|
20
|
+
seen = new Map();
|
|
21
|
+
constructor(ttlMs = DEFAULT_DEDUPE_TTL_MS) {
|
|
22
|
+
this.ttlMs = ttlMs;
|
|
23
|
+
}
|
|
24
|
+
has(eventId) {
|
|
25
|
+
const expiresAt = this.seen.get(eventId);
|
|
26
|
+
if (expiresAt === undefined)
|
|
27
|
+
return false;
|
|
28
|
+
if (expiresAt <= Date.now()) {
|
|
29
|
+
this.seen.delete(eventId);
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
add(eventId) {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
for (const [id, expiresAt] of this.seen) {
|
|
37
|
+
if (expiresAt <= now)
|
|
38
|
+
this.seen.delete(id);
|
|
39
|
+
}
|
|
40
|
+
this.seen.set(eventId, now + this.ttlMs);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export class Webhooks {
|
|
44
|
+
secret;
|
|
45
|
+
store;
|
|
46
|
+
constructor(secret, store) {
|
|
47
|
+
this.secret = secret ?? null;
|
|
48
|
+
this.store = store ?? new InMemoryProcessedEventStore();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Verify a webhook delivery against its "Pulse-Signature" header and return
|
|
52
|
+
* the typed event. `payload` MUST be the raw request body — a re-serialized
|
|
53
|
+
* JSON object will not match the signature.
|
|
54
|
+
* Throws SignatureVerificationError on any mismatch.
|
|
55
|
+
*/
|
|
56
|
+
verify(payload, signatureHeader, secret, options = {}) {
|
|
57
|
+
const resolvedSecret = secret ?? this.secret;
|
|
58
|
+
if (resolvedSecret === null || resolvedSecret === '') {
|
|
59
|
+
throw new SignatureVerificationError('No webhook secret provided — pass one to verify() or the Pulse constructor');
|
|
60
|
+
}
|
|
61
|
+
const raw = typeof payload === 'string' ? payload : payload.toString('utf8');
|
|
62
|
+
const { timestamp, signatures } = parseSignatureHeader(signatureHeader);
|
|
63
|
+
const expected = createHmac('sha256', resolvedSecret)
|
|
64
|
+
.update(`${timestamp}.${raw}`)
|
|
65
|
+
.digest('hex');
|
|
66
|
+
const expectedBuf = Buffer.from(expected, 'utf8');
|
|
67
|
+
const matches = signatures.some((candidate) => {
|
|
68
|
+
const candidateBuf = Buffer.from(candidate, 'utf8');
|
|
69
|
+
return (candidateBuf.length === expectedBuf.length && timingSafeEqual(candidateBuf, expectedBuf));
|
|
70
|
+
});
|
|
71
|
+
if (!matches) {
|
|
72
|
+
throw new SignatureVerificationError('Webhook signature does not match the payload');
|
|
73
|
+
}
|
|
74
|
+
const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
|
|
75
|
+
const nowSeconds = options.now?.() ?? Math.floor(Date.now() / 1000);
|
|
76
|
+
if (Math.abs(nowSeconds - timestamp) > tolerance) {
|
|
77
|
+
throw new SignatureVerificationError(`Webhook timestamp outside tolerance (${tolerance}s) — possible replay`);
|
|
78
|
+
}
|
|
79
|
+
return parseEvent(raw);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Verify a delivery AND de-duplicate on event id in one call. Delivery is
|
|
83
|
+
* at-least-once, so redeliveries WILL arrive: the first sight of an id
|
|
84
|
+
* returns the typed event, a duplicate returns null (skip it, respond 200).
|
|
85
|
+
* Signature failures still throw before the store is touched.
|
|
86
|
+
*/
|
|
87
|
+
async verifyOnce(payload, signatureHeader, secret, options = {}) {
|
|
88
|
+
const event = this.verify(payload, signatureHeader, secret, options);
|
|
89
|
+
if (await this.store.has(event.id)) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
await this.store.add(event.id);
|
|
93
|
+
return event;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function parseSignatureHeader(header) {
|
|
97
|
+
let timestamp = null;
|
|
98
|
+
const signatures = [];
|
|
99
|
+
for (const part of header.split(',')) {
|
|
100
|
+
const eq = part.indexOf('=');
|
|
101
|
+
if (eq === -1)
|
|
102
|
+
continue;
|
|
103
|
+
const key = part.slice(0, eq).trim();
|
|
104
|
+
const value = part.slice(eq + 1).trim();
|
|
105
|
+
if (key === 't') {
|
|
106
|
+
const parsed = Number(value);
|
|
107
|
+
if (Number.isInteger(parsed) && parsed > 0)
|
|
108
|
+
timestamp = parsed;
|
|
109
|
+
}
|
|
110
|
+
else if (key === 'v1' && value !== '') {
|
|
111
|
+
signatures.push(value);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (timestamp === null || signatures.length === 0) {
|
|
115
|
+
throw new SignatureVerificationError('Malformed Pulse-Signature header');
|
|
116
|
+
}
|
|
117
|
+
return { timestamp, signatures };
|
|
118
|
+
}
|
|
119
|
+
function parseEvent(raw) {
|
|
120
|
+
let parsed;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(raw);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
throw new SignatureVerificationError('Webhook payload is not valid JSON');
|
|
126
|
+
}
|
|
127
|
+
if (typeof parsed !== 'object' ||
|
|
128
|
+
parsed === null ||
|
|
129
|
+
!('id' in parsed) ||
|
|
130
|
+
!('type' in parsed) ||
|
|
131
|
+
!('data' in parsed)) {
|
|
132
|
+
throw new SignatureVerificationError('Webhook payload is not an event envelope');
|
|
133
|
+
}
|
|
134
|
+
return parsed;
|
|
135
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pulsebyshiga/node",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Pulse Collect backend SDK — collection sessions, orders, and webhook signature verification",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Shiga Digital",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Pulse-Digital/pulse-collect.git",
|
|
10
|
+
"directory": "packages/node-sdk"
|
|
11
|
+
},
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18.17"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^22.10.0",
|
|
30
|
+
"typescript": "^5.6.3",
|
|
31
|
+
"vitest": "^3.0.0"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.build.json",
|
|
35
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
36
|
+
"test": "vitest run"
|
|
37
|
+
}
|
|
38
|
+
}
|