@ethitrust/sdk 1.0.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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -0
  3. package/dist/client.d.ts +42 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +45 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/errors.d.ts +66 -0
  8. package/dist/errors.d.ts.map +1 -0
  9. package/dist/errors.js +115 -0
  10. package/dist/errors.js.map +1 -0
  11. package/dist/http.d.ts +49 -0
  12. package/dist/http.d.ts.map +1 -0
  13. package/dist/http.js +193 -0
  14. package/dist/http.js.map +1 -0
  15. package/dist/index.d.ts +10 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +7 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/resources/orgEscrows.d.ts +53 -0
  20. package/dist/resources/orgEscrows.d.ts.map +1 -0
  21. package/dist/resources/orgEscrows.js +168 -0
  22. package/dist/resources/orgEscrows.js.map +1 -0
  23. package/dist/types.d.ts +229 -0
  24. package/dist/types.d.ts.map +1 -0
  25. package/dist/types.js +6 -0
  26. package/dist/types.js.map +1 -0
  27. package/dist/utils/idempotency.d.ts +9 -0
  28. package/dist/utils/idempotency.d.ts.map +1 -0
  29. package/dist/utils/idempotency.js +25 -0
  30. package/dist/utils/idempotency.js.map +1 -0
  31. package/dist/utils/query.d.ts +8 -0
  32. package/dist/utils/query.d.ts.map +1 -0
  33. package/dist/utils/query.js +35 -0
  34. package/dist/utils/query.js.map +1 -0
  35. package/package.json +51 -0
  36. package/src/client.ts +69 -0
  37. package/src/errors.ts +140 -0
  38. package/src/http.ts +246 -0
  39. package/src/index.ts +23 -0
  40. package/src/resources/orgEscrows.ts +246 -0
  41. package/src/types.ts +286 -0
  42. package/src/utils/idempotency.ts +23 -0
  43. package/src/utils/query.ts +31 -0
package/src/http.ts ADDED
@@ -0,0 +1,246 @@
1
+ import {
2
+ EthitrustAbortError,
3
+ EthitrustNetworkError,
4
+ EthitrustRateLimitError,
5
+ buildApiError,
6
+ } from './errors.js';
7
+ import { toQueryString } from './utils/query.js';
8
+
9
+ export interface HttpClientOptions {
10
+ apiKey: string;
11
+ baseUrl: string;
12
+ /** Header name carrying the API key. Defaults to `X-API-Key`. */
13
+ apiKeyHeader?: string;
14
+ /** Per-request timeout in milliseconds. Defaults to 30 000. */
15
+ timeoutMs?: number;
16
+ /** Max retries for transient failures (network, 429, 5xx). Defaults to 2. */
17
+ maxRetries?: number;
18
+ /** Base delay (ms) for exponential backoff. Defaults to 300. */
19
+ retryBaseDelayMs?: number;
20
+ /** Custom fetch implementation. Defaults to global `fetch`. */
21
+ fetch?: typeof fetch;
22
+ /** Extra headers added to every request. */
23
+ defaultHeaders?: Record<string, string>;
24
+ /** User agent appended to `User-Agent` (Node only). */
25
+ userAgent?: string;
26
+ }
27
+
28
+ export interface RequestOptions {
29
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
30
+ path: string;
31
+ query?: Record<string, unknown>;
32
+ body?: unknown;
33
+ /** Adds an `X-Idempotency-Key` header. */
34
+ idempotencyKey?: string;
35
+ /** Overrides per-request timeout. */
36
+ timeoutMs?: number;
37
+ /** Abort signal supplied by the caller. */
38
+ signal?: AbortSignal;
39
+ /** Extra headers for this request. */
40
+ headers?: Record<string, string>;
41
+ }
42
+
43
+ export class HttpClient {
44
+ readonly baseUrl: string;
45
+ private readonly apiKey: string;
46
+ private readonly apiKeyHeader: string;
47
+ private readonly timeoutMs: number;
48
+ private readonly maxRetries: number;
49
+ private readonly retryBaseDelayMs: number;
50
+ private readonly fetchImpl: typeof fetch;
51
+ private readonly defaultHeaders: Record<string, string>;
52
+
53
+ constructor(opts: HttpClientOptions) {
54
+ if (!opts.apiKey) throw new Error('apiKey is required');
55
+ if (!opts.baseUrl) throw new Error('baseUrl is required');
56
+ this.apiKey = opts.apiKey;
57
+ this.apiKeyHeader = opts.apiKeyHeader ?? 'X-API-Key';
58
+ this.baseUrl = stripTrailingSlash(opts.baseUrl);
59
+ this.timeoutMs = opts.timeoutMs ?? 30_000;
60
+ this.maxRetries = opts.maxRetries ?? 2;
61
+ this.retryBaseDelayMs = opts.retryBaseDelayMs ?? 300;
62
+ const fImpl = opts.fetch ?? globalThis.fetch;
63
+ if (!fImpl) {
64
+ throw new Error(
65
+ 'No fetch implementation found. Use Node 18+ or pass `fetch` explicitly.',
66
+ );
67
+ }
68
+ this.fetchImpl = fImpl.bind(globalThis);
69
+ this.defaultHeaders = {
70
+ Accept: 'application/json',
71
+ 'User-Agent': opts.userAgent ?? '@ethitrust/sdk/1.0.0 node',
72
+ ...opts.defaultHeaders,
73
+ };
74
+ }
75
+
76
+ async request<T>(opts: RequestOptions): Promise<T> {
77
+ const url = this.buildUrl(opts.path, opts.query);
78
+ const headers = this.buildHeaders(opts);
79
+
80
+ const hasBody = opts.body !== undefined && opts.body !== null;
81
+ const init: RequestInit = {
82
+ method: opts.method,
83
+ headers,
84
+ body: hasBody ? JSON.stringify(opts.body) : undefined,
85
+ };
86
+
87
+ let attempt = 0;
88
+ // eslint-disable-next-line no-constant-condition
89
+ while (true) {
90
+ const { signal, cancel } = mergeSignals(
91
+ opts.signal,
92
+ opts.timeoutMs ?? this.timeoutMs,
93
+ );
94
+ try {
95
+ const response = await this.fetchImpl(url, { ...init, signal });
96
+ cancel();
97
+ if (response.ok) {
98
+ return (await parseBody(response)) as T;
99
+ }
100
+
101
+ const body = await parseBody(response);
102
+ const retryAfter = parseRetryAfter(response.headers.get('Retry-After'));
103
+ const err = buildApiError({
104
+ status: response.status,
105
+ statusText: response.statusText,
106
+ url,
107
+ method: opts.method,
108
+ body,
109
+ requestId: response.headers.get('X-Request-Id') ?? undefined,
110
+ retryAfter,
111
+ });
112
+
113
+ if (this.shouldRetry(response.status, attempt) && opts.method === 'GET') {
114
+ await sleep(this.backoff(attempt, retryAfter));
115
+ attempt++;
116
+ continue;
117
+ }
118
+ // Always retry 429s regardless of method, since the server signals it.
119
+ if (response.status === 429 && attempt < this.maxRetries) {
120
+ await sleep(this.backoff(attempt, retryAfter));
121
+ attempt++;
122
+ continue;
123
+ }
124
+ throw err;
125
+ } catch (err) {
126
+ cancel();
127
+ if (err instanceof EthitrustRateLimitError) throw err;
128
+ if (isAbortError(err)) {
129
+ if (opts.signal?.aborted) throw new EthitrustAbortError();
130
+ // Timeout: treat as network error, eligible for retry on GET.
131
+ if (opts.method === 'GET' && attempt < this.maxRetries) {
132
+ await sleep(this.backoff(attempt));
133
+ attempt++;
134
+ continue;
135
+ }
136
+ throw new EthitrustNetworkError('Request timed out', err);
137
+ }
138
+ // Non-API error rethrown (validation, etc.).
139
+ if (err && typeof err === 'object' && 'status' in (err as object)) {
140
+ throw err;
141
+ }
142
+ if (opts.method === 'GET' && attempt < this.maxRetries) {
143
+ await sleep(this.backoff(attempt));
144
+ attempt++;
145
+ continue;
146
+ }
147
+ throw new EthitrustNetworkError(
148
+ err instanceof Error ? err.message : 'Network error',
149
+ err,
150
+ );
151
+ }
152
+ }
153
+ }
154
+
155
+ private buildUrl(path: string, query?: Record<string, unknown>): string {
156
+ const p = path.startsWith('/') ? path : `/${path}`;
157
+ const qs = query ? toQueryString(query) : '';
158
+ return `${this.baseUrl}${p}${qs}`;
159
+ }
160
+
161
+ private buildHeaders(opts: RequestOptions): Headers {
162
+ const h = new Headers(this.defaultHeaders);
163
+ h.set(this.apiKeyHeader, this.apiKey);
164
+ if (opts.body !== undefined && opts.body !== null) {
165
+ h.set('Content-Type', 'application/json');
166
+ }
167
+ if (opts.idempotencyKey) {
168
+ h.set('X-Idempotency-Key', opts.idempotencyKey);
169
+ }
170
+ if (opts.headers) {
171
+ for (const [k, v] of Object.entries(opts.headers)) h.set(k, v);
172
+ }
173
+ return h;
174
+ }
175
+
176
+ private shouldRetry(status: number, attempt: number): boolean {
177
+ if (attempt >= this.maxRetries) return false;
178
+ return status >= 500 && status < 600;
179
+ }
180
+
181
+ private backoff(attempt: number, retryAfterSec?: number): number {
182
+ if (retryAfterSec && retryAfterSec > 0) return retryAfterSec * 1000;
183
+ const jitter = Math.random() * 0.25 + 0.875; // 0.875–1.125
184
+ return Math.min(this.retryBaseDelayMs * 2 ** attempt * jitter, 10_000);
185
+ }
186
+ }
187
+
188
+ function stripTrailingSlash(s: string): string {
189
+ return s.endsWith('/') ? s.slice(0, -1) : s;
190
+ }
191
+
192
+ async function parseBody(res: Response): Promise<unknown> {
193
+ if (res.status === 204) return undefined;
194
+ const ct = res.headers.get('Content-Type') ?? '';
195
+ if (ct.includes('application/json')) {
196
+ try {
197
+ return await res.json();
198
+ } catch {
199
+ return undefined;
200
+ }
201
+ }
202
+ const txt = await res.text();
203
+ return txt || undefined;
204
+ }
205
+
206
+ function parseRetryAfter(h: string | null): number | undefined {
207
+ if (!h) return undefined;
208
+ const n = Number(h);
209
+ if (Number.isFinite(n)) return n;
210
+ const date = Date.parse(h);
211
+ if (Number.isFinite(date)) {
212
+ return Math.max(0, Math.round((date - Date.now()) / 1000));
213
+ }
214
+ return undefined;
215
+ }
216
+
217
+ function mergeSignals(
218
+ user: AbortSignal | undefined,
219
+ timeoutMs: number,
220
+ ): { signal: AbortSignal; cancel: () => void } {
221
+ const ctrl = new AbortController();
222
+ const onUserAbort = () => ctrl.abort(user?.reason);
223
+ if (user) {
224
+ if (user.aborted) ctrl.abort(user.reason);
225
+ else user.addEventListener('abort', onUserAbort, { once: true });
226
+ }
227
+ const timer = setTimeout(() => ctrl.abort(new Error('timeout')), timeoutMs);
228
+ return {
229
+ signal: ctrl.signal,
230
+ cancel: () => {
231
+ clearTimeout(timer);
232
+ if (user) user.removeEventListener('abort', onUserAbort);
233
+ },
234
+ };
235
+ }
236
+
237
+ function isAbortError(err: unknown): boolean {
238
+ return (
239
+ err instanceof Error &&
240
+ (err.name === 'AbortError' || err.name === 'TimeoutError')
241
+ );
242
+ }
243
+
244
+ function sleep(ms: number): Promise<void> {
245
+ return new Promise((r) => setTimeout(r, ms));
246
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ export { EthitrustClient } from './client.js';
2
+ export type { EthitrustClientOptions } from './client.js';
3
+
4
+ export { HttpClient } from './http.js';
5
+ export type { HttpClientOptions, RequestOptions } from './http.js';
6
+
7
+ export { OrgEscrowsResource } from './resources/orgEscrows.js';
8
+ export type { RequestExtras } from './resources/orgEscrows.js';
9
+
10
+ export { generateIdempotencyKey } from './utils/idempotency.js';
11
+
12
+ export * from './types.js';
13
+ export {
14
+ EthitrustError,
15
+ EthitrustApiError,
16
+ EthitrustAuthError,
17
+ EthitrustNotFoundError,
18
+ EthitrustConflictError,
19
+ EthitrustValidationError,
20
+ EthitrustRateLimitError,
21
+ EthitrustNetworkError,
22
+ EthitrustAbortError,
23
+ } from './errors.js';
@@ -0,0 +1,246 @@
1
+ import type { HttpClient } from '../http.js';
2
+ import type {
3
+ InitializeEscrowResponse,
4
+ ListOrgEscrowsParams,
5
+ OrgEscrowAuditTrailResponse,
6
+ OrgEscrowCancelResponse,
7
+ OrgEscrowDetailResponse,
8
+ OrgEscrowHealthResponse,
9
+ OrgEscrowListItem,
10
+ OrgEscrowListResponse,
11
+ OrgEscrowReportParams,
12
+ OrgEscrowReportResponse,
13
+ OrgEscrowStatusResponse,
14
+ OrganizationInitializeEscrowRequest,
15
+ WebhookLogEntry,
16
+ WebhookTestResponse,
17
+ } from '../types.js';
18
+ import { generateIdempotencyKey } from '../utils/idempotency.js';
19
+
20
+ export interface RequestExtras {
21
+ /**
22
+ * Idempotency key for write operations. If omitted, the SDK auto-generates
23
+ * a UUID v4 so safe retries remain idempotent.
24
+ * Pass `null` to explicitly disable the header.
25
+ */
26
+ idempotencyKey?: string | null;
27
+ signal?: AbortSignal;
28
+ timeoutMs?: number;
29
+ headers?: Record<string, string>;
30
+ }
31
+
32
+ export class OrgEscrowsResource {
33
+ constructor(private readonly http: HttpClient) {}
34
+
35
+ // ── CRUD-ish ──────────────────────────────────────────────────────────────
36
+
37
+ /** POST /api/v1/org-escrows — initialise a new escrow on behalf of the org. */
38
+ create(
39
+ body: OrganizationInitializeEscrowRequest,
40
+ extras: RequestExtras = {},
41
+ ): Promise<InitializeEscrowResponse> {
42
+ return this.http.request<InitializeEscrowResponse>({
43
+ method: 'POST',
44
+ path: '/api/v1/org-escrows',
45
+ body,
46
+ idempotencyKey: resolveIdempotencyKey(extras.idempotencyKey),
47
+ signal: extras.signal,
48
+ timeoutMs: extras.timeoutMs,
49
+ headers: extras.headers,
50
+ });
51
+ }
52
+
53
+ /** GET /api/v1/org-escrows — paginated, filterable list. */
54
+ list(
55
+ params: ListOrgEscrowsParams = {},
56
+ extras: Omit<RequestExtras, 'idempotencyKey'> = {},
57
+ ): Promise<OrgEscrowListResponse> {
58
+ return this.http.request<OrgEscrowListResponse>({
59
+ method: 'GET',
60
+ path: '/api/v1/org-escrows',
61
+ query: params as Record<string, unknown>,
62
+ signal: extras.signal,
63
+ timeoutMs: extras.timeoutMs,
64
+ headers: extras.headers,
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Async iterator over every page of org escrows. Yields individual
70
+ * `OrgEscrowListItem` records.
71
+ *
72
+ * ```ts
73
+ * for await (const e of client.orgEscrows.iter({ status: 'active' })) {
74
+ * console.log(e.escrow_id);
75
+ * }
76
+ * ```
77
+ */
78
+ async *iter(
79
+ params: ListOrgEscrowsParams = {},
80
+ extras: Omit<RequestExtras, 'idempotencyKey'> = {},
81
+ ): AsyncGenerator<OrgEscrowListItem, void, void> {
82
+ let page = params.page ?? 1;
83
+ const pageSize = params.page_size ?? 20;
84
+ // eslint-disable-next-line no-constant-condition
85
+ while (true) {
86
+ const res = await this.list({ ...params, page, page_size: pageSize }, extras);
87
+ for (const item of res.items) yield item;
88
+ if (page >= res.total_pages || res.items.length === 0) return;
89
+ page++;
90
+ }
91
+ }
92
+
93
+ /** GET /api/v1/org-escrows/{escrow_id} — lightweight status snapshot. */
94
+ getStatus(
95
+ escrowId: string,
96
+ extras: Omit<RequestExtras, 'idempotencyKey'> = {},
97
+ ): Promise<OrgEscrowStatusResponse> {
98
+ return this.http.request<OrgEscrowStatusResponse>({
99
+ method: 'GET',
100
+ path: `/api/v1/org-escrows/${encodeURIComponent(escrowId)}`,
101
+ signal: extras.signal,
102
+ timeoutMs: extras.timeoutMs,
103
+ headers: extras.headers,
104
+ });
105
+ }
106
+
107
+ /** GET /api/v1/org-escrows/{escrow_id}/detail — full detail incl. progress, risk flags. */
108
+ getDetail(
109
+ escrowId: string,
110
+ extras: Omit<RequestExtras, 'idempotencyKey'> = {},
111
+ ): Promise<OrgEscrowDetailResponse> {
112
+ return this.http.request<OrgEscrowDetailResponse>({
113
+ method: 'GET',
114
+ path: `/api/v1/org-escrows/${encodeURIComponent(escrowId)}/detail`,
115
+ signal: extras.signal,
116
+ timeoutMs: extras.timeoutMs,
117
+ headers: extras.headers,
118
+ });
119
+ }
120
+
121
+ /** GET /api/v1/org-escrows/{escrow_id}/events — audit trail. */
122
+ getEvents(
123
+ escrowId: string,
124
+ extras: Omit<RequestExtras, 'idempotencyKey'> = {},
125
+ ): Promise<OrgEscrowAuditTrailResponse> {
126
+ return this.http.request<OrgEscrowAuditTrailResponse>({
127
+ method: 'GET',
128
+ path: `/api/v1/org-escrows/${encodeURIComponent(escrowId)}/events`,
129
+ signal: extras.signal,
130
+ timeoutMs: extras.timeoutMs,
131
+ headers: extras.headers,
132
+ });
133
+ }
134
+
135
+ /** GET /api/v1/org-escrows/{escrow_id}/health — boolean state flags. */
136
+ getHealth(
137
+ escrowId: string,
138
+ extras: Omit<RequestExtras, 'idempotencyKey'> = {},
139
+ ): Promise<OrgEscrowHealthResponse> {
140
+ return this.http.request<OrgEscrowHealthResponse>({
141
+ method: 'GET',
142
+ path: `/api/v1/org-escrows/${encodeURIComponent(escrowId)}/health`,
143
+ signal: extras.signal,
144
+ timeoutMs: extras.timeoutMs,
145
+ headers: extras.headers,
146
+ });
147
+ }
148
+
149
+ /** POST /api/v1/org-escrows/{escrow_id}/cancel — cancel & refund (if eligible). */
150
+ cancel(
151
+ escrowId: string,
152
+ extras: RequestExtras = {},
153
+ ): Promise<OrgEscrowCancelResponse> {
154
+ return this.http.request<OrgEscrowCancelResponse>({
155
+ method: 'POST',
156
+ path: `/api/v1/org-escrows/${encodeURIComponent(escrowId)}/cancel`,
157
+ idempotencyKey: resolveIdempotencyKey(extras.idempotencyKey),
158
+ signal: extras.signal,
159
+ timeoutMs: extras.timeoutMs,
160
+ headers: extras.headers,
161
+ });
162
+ }
163
+
164
+ /** POST /api/v1/org-escrows/{escrow_id}/resend — resend the invitation email. */
165
+ resendInvitation(
166
+ escrowId: string,
167
+ extras: RequestExtras = {},
168
+ ): Promise<InitializeEscrowResponse> {
169
+ return this.http.request<InitializeEscrowResponse>({
170
+ method: 'POST',
171
+ path: `/api/v1/org-escrows/${encodeURIComponent(escrowId)}/resend`,
172
+ idempotencyKey: resolveIdempotencyKey(extras.idempotencyKey),
173
+ signal: extras.signal,
174
+ timeoutMs: extras.timeoutMs,
175
+ headers: extras.headers,
176
+ });
177
+ }
178
+
179
+ // ── Reports ───────────────────────────────────────────────────────────────────
180
+
181
+ /** GET /api/v1/org-escrows/reports/summary — org-wide aggregates over a period. */
182
+ getReport(
183
+ params: OrgEscrowReportParams = {},
184
+ extras: Omit<RequestExtras, 'idempotencyKey'> = {},
185
+ ): Promise<OrgEscrowReportResponse> {
186
+ return this.http.request<OrgEscrowReportResponse>({
187
+ method: 'GET',
188
+ path: '/api/v1/org-escrows/reports/summary',
189
+ query: params as Record<string, unknown>,
190
+ signal: extras.signal,
191
+ timeoutMs: extras.timeoutMs,
192
+ headers: extras.headers,
193
+ });
194
+ }
195
+
196
+ // ── Webhooks ─────────────────────────────────────────────────────────────────
197
+
198
+ /** GET /api/v1/org-escrows/{escrow_id}/webhooks — deliveries for one escrow. */
199
+ listEscrowWebhookLogs(
200
+ escrowId: string,
201
+ extras: Omit<RequestExtras, 'idempotencyKey'> = {},
202
+ ): Promise<WebhookLogEntry[]> {
203
+ return this.http.request<WebhookLogEntry[]>({
204
+ method: 'GET',
205
+ path: `/api/v1/org-escrows/${encodeURIComponent(escrowId)}/webhooks`,
206
+ signal: extras.signal,
207
+ timeoutMs: extras.timeoutMs,
208
+ headers: extras.headers,
209
+ });
210
+ }
211
+
212
+ /** GET /api/v1/org-escrows/webhooks — 50 most recent deliveries across the org. */
213
+ listWebhookLogs(
214
+ extras: Omit<RequestExtras, 'idempotencyKey'> = {},
215
+ ): Promise<WebhookLogEntry[]> {
216
+ return this.http.request<WebhookLogEntry[]>({
217
+ method: 'GET',
218
+ path: '/api/v1/org-escrows/webhooks',
219
+ signal: extras.signal,
220
+ timeoutMs: extras.timeoutMs,
221
+ headers: extras.headers,
222
+ });
223
+ }
224
+
225
+ /** POST /api/v1/org-escrows/webhooks/test — send a synthetic delivery. */
226
+ testWebhook(
227
+ extras: RequestExtras = {},
228
+ ): Promise<WebhookTestResponse> {
229
+ return this.http.request<WebhookTestResponse>({
230
+ method: 'POST',
231
+ path: '/api/v1/org-escrows/webhooks/test',
232
+ idempotencyKey: resolveIdempotencyKey(extras.idempotencyKey),
233
+ signal: extras.signal,
234
+ timeoutMs: extras.timeoutMs,
235
+ headers: extras.headers,
236
+ });
237
+ }
238
+ }
239
+
240
+ function resolveIdempotencyKey(
241
+ k: string | null | undefined,
242
+ ): string | undefined {
243
+ if (k === null) return undefined; // explicitly disabled
244
+ if (k === undefined) return generateIdempotencyKey();
245
+ return k;
246
+ }