blockintel-gate-sdk 0.1.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 +22 -0
- package/README.md +386 -0
- package/dist/index.cjs +1193 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +492 -0
- package/dist/index.d.ts +492 -0
- package/dist/index.js +1179 -0
- package/dist/index.js.map +1 -0
- package/package.json +89 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1179 @@
|
|
|
1
|
+
import { v4 } from 'uuid';
|
|
2
|
+
import { SignCommand } from '@aws-sdk/client-kms';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
|
|
5
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
6
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
7
|
+
}) : x)(function(x) {
|
|
8
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
9
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// src/utils/crypto.ts
|
|
13
|
+
async function hmacSha256(secret, message) {
|
|
14
|
+
if (typeof crypto !== "undefined" && crypto.subtle) {
|
|
15
|
+
const encoder = new TextEncoder();
|
|
16
|
+
const keyData = encoder.encode(secret);
|
|
17
|
+
const messageData = encoder.encode(message);
|
|
18
|
+
const key = await crypto.subtle.importKey(
|
|
19
|
+
"raw",
|
|
20
|
+
keyData,
|
|
21
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
22
|
+
false,
|
|
23
|
+
["sign"]
|
|
24
|
+
);
|
|
25
|
+
const signature = await crypto.subtle.sign("HMAC", key, messageData);
|
|
26
|
+
const hashArray = Array.from(new Uint8Array(signature));
|
|
27
|
+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
28
|
+
}
|
|
29
|
+
if (typeof __require !== "undefined") {
|
|
30
|
+
const crypto2 = __require("crypto");
|
|
31
|
+
const hmac = crypto2.createHmac("sha256", secret);
|
|
32
|
+
hmac.update(message, "utf8");
|
|
33
|
+
return hmac.digest("hex");
|
|
34
|
+
}
|
|
35
|
+
throw new Error("HMAC-SHA256 not available in this environment");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/utils/canonicalJson.ts
|
|
39
|
+
function canonicalizeJson(obj) {
|
|
40
|
+
if (obj === null || obj === void 0) {
|
|
41
|
+
return "null";
|
|
42
|
+
}
|
|
43
|
+
if (typeof obj === "string") {
|
|
44
|
+
return JSON.stringify(obj);
|
|
45
|
+
}
|
|
46
|
+
if (typeof obj === "number" || typeof obj === "boolean") {
|
|
47
|
+
return String(obj);
|
|
48
|
+
}
|
|
49
|
+
if (Array.isArray(obj)) {
|
|
50
|
+
const items = obj.map((item) => canonicalizeJson(item));
|
|
51
|
+
return `[${items.join(",")}]`;
|
|
52
|
+
}
|
|
53
|
+
if (typeof obj === "object") {
|
|
54
|
+
const keys = Object.keys(obj).sort();
|
|
55
|
+
const pairs = keys.map((key) => {
|
|
56
|
+
const value = obj[key];
|
|
57
|
+
const canonicalValue = canonicalizeJson(value);
|
|
58
|
+
return `${JSON.stringify(key)}:${canonicalValue}`;
|
|
59
|
+
});
|
|
60
|
+
return `{${pairs.join(",")}}`;
|
|
61
|
+
}
|
|
62
|
+
return JSON.stringify(obj);
|
|
63
|
+
}
|
|
64
|
+
async function sha256Hex(input) {
|
|
65
|
+
if (typeof crypto !== "undefined" && crypto.subtle) {
|
|
66
|
+
const encoder = new TextEncoder();
|
|
67
|
+
const data = encoder.encode(input);
|
|
68
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
69
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
70
|
+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
71
|
+
}
|
|
72
|
+
if (typeof __require !== "undefined") {
|
|
73
|
+
const crypto2 = __require("crypto");
|
|
74
|
+
return crypto2.createHash("sha256").update(input, "utf8").digest("hex");
|
|
75
|
+
}
|
|
76
|
+
throw new Error("SHA-256 not available in this environment");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/auth/HmacSigner.ts
|
|
80
|
+
var HmacSigner = class {
|
|
81
|
+
keyId;
|
|
82
|
+
secret;
|
|
83
|
+
constructor(config) {
|
|
84
|
+
this.keyId = config.keyId;
|
|
85
|
+
this.secret = config.secret;
|
|
86
|
+
if (!this.secret || this.secret.length === 0) {
|
|
87
|
+
throw new Error("HMAC secret cannot be empty");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Sign a request and return headers
|
|
92
|
+
*/
|
|
93
|
+
async signRequest(params) {
|
|
94
|
+
const { method, path, tenantId, timestampMs, requestId, body } = params;
|
|
95
|
+
const bodyJson = body ? canonicalizeJson(body) : "";
|
|
96
|
+
const bodyHash = await sha256Hex(bodyJson);
|
|
97
|
+
const signingString = [
|
|
98
|
+
"v1",
|
|
99
|
+
method.toUpperCase(),
|
|
100
|
+
path,
|
|
101
|
+
tenantId,
|
|
102
|
+
this.keyId,
|
|
103
|
+
String(timestampMs),
|
|
104
|
+
requestId,
|
|
105
|
+
// Used as nonce in canonical string
|
|
106
|
+
bodyHash
|
|
107
|
+
].join("\n");
|
|
108
|
+
const signature = await hmacSha256(this.secret, signingString);
|
|
109
|
+
return {
|
|
110
|
+
"X-GATE-TENANT-ID": tenantId,
|
|
111
|
+
"X-GATE-KEY-ID": this.keyId,
|
|
112
|
+
"X-GATE-TIMESTAMP-MS": String(timestampMs),
|
|
113
|
+
"X-GATE-REQUEST-ID": requestId,
|
|
114
|
+
"X-GATE-SIGNATURE": signature
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// src/auth/ApiKeyAuth.ts
|
|
120
|
+
var ApiKeyAuth = class {
|
|
121
|
+
apiKey;
|
|
122
|
+
constructor(config) {
|
|
123
|
+
this.apiKey = config.apiKey;
|
|
124
|
+
if (!this.apiKey || this.apiKey.length === 0) {
|
|
125
|
+
throw new Error("API key cannot be empty");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Create headers for API key authentication
|
|
130
|
+
*/
|
|
131
|
+
createHeaders(params) {
|
|
132
|
+
const { tenantId, timestampMs, requestId } = params;
|
|
133
|
+
return {
|
|
134
|
+
"X-API-KEY": this.apiKey,
|
|
135
|
+
"X-GATE-TENANT-ID": tenantId,
|
|
136
|
+
"X-GATE-REQUEST-ID": requestId,
|
|
137
|
+
"X-GATE-TIMESTAMP-MS": String(timestampMs)
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// src/types/errors.ts
|
|
143
|
+
var GateErrorCode = /* @__PURE__ */ ((GateErrorCode2) => {
|
|
144
|
+
GateErrorCode2["NETWORK_ERROR"] = "NETWORK_ERROR";
|
|
145
|
+
GateErrorCode2["TIMEOUT"] = "TIMEOUT";
|
|
146
|
+
GateErrorCode2["NOT_FOUND"] = "NOT_FOUND";
|
|
147
|
+
GateErrorCode2["UNAUTHORIZED"] = "UNAUTHORIZED";
|
|
148
|
+
GateErrorCode2["FORBIDDEN"] = "FORBIDDEN";
|
|
149
|
+
GateErrorCode2["RATE_LIMITED"] = "RATE_LIMITED";
|
|
150
|
+
GateErrorCode2["SERVER_ERROR"] = "SERVER_ERROR";
|
|
151
|
+
GateErrorCode2["INVALID_RESPONSE"] = "INVALID_RESPONSE";
|
|
152
|
+
GateErrorCode2["STEP_UP_NOT_CONFIGURED"] = "STEP_UP_NOT_CONFIGURED";
|
|
153
|
+
GateErrorCode2["STEP_UP_TIMEOUT"] = "STEP_UP_TIMEOUT";
|
|
154
|
+
GateErrorCode2["BLOCKED"] = "BLOCKED";
|
|
155
|
+
GateErrorCode2["SERVICE_UNAVAILABLE"] = "SERVICE_UNAVAILABLE";
|
|
156
|
+
GateErrorCode2["AUTH_ERROR"] = "AUTH_ERROR";
|
|
157
|
+
return GateErrorCode2;
|
|
158
|
+
})(GateErrorCode || {});
|
|
159
|
+
var GateError = class extends Error {
|
|
160
|
+
code;
|
|
161
|
+
status;
|
|
162
|
+
details;
|
|
163
|
+
requestId;
|
|
164
|
+
correlationId;
|
|
165
|
+
constructor(code, message, options) {
|
|
166
|
+
super(message);
|
|
167
|
+
this.name = "GateError";
|
|
168
|
+
this.code = code;
|
|
169
|
+
this.status = options?.status;
|
|
170
|
+
this.details = options?.details;
|
|
171
|
+
this.requestId = options?.requestId;
|
|
172
|
+
this.correlationId = options?.correlationId;
|
|
173
|
+
if (options?.cause) {
|
|
174
|
+
this.cause = options.cause;
|
|
175
|
+
}
|
|
176
|
+
Error.captureStackTrace(this, this.constructor);
|
|
177
|
+
}
|
|
178
|
+
toJSON() {
|
|
179
|
+
return {
|
|
180
|
+
name: this.name,
|
|
181
|
+
code: this.code,
|
|
182
|
+
message: this.message,
|
|
183
|
+
status: this.status,
|
|
184
|
+
details: this.details,
|
|
185
|
+
requestId: this.requestId,
|
|
186
|
+
correlationId: this.correlationId
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
var StepUpNotConfiguredError = class extends GateError {
|
|
191
|
+
constructor(requestId) {
|
|
192
|
+
super(
|
|
193
|
+
"STEP_UP_NOT_CONFIGURED" /* STEP_UP_NOT_CONFIGURED */,
|
|
194
|
+
"Step-up is required but not configured in SDK. Enable step-up in client config or treat REQUIRE_STEP_UP as BLOCK.",
|
|
195
|
+
{ requestId }
|
|
196
|
+
);
|
|
197
|
+
this.name = "StepUpNotConfiguredError";
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
var BlockIntelBlockedError = class extends GateError {
|
|
201
|
+
receiptId;
|
|
202
|
+
reasonCode;
|
|
203
|
+
constructor(reasonCode, receiptId, correlationId, requestId) {
|
|
204
|
+
super(
|
|
205
|
+
"BLOCKED" /* BLOCKED */,
|
|
206
|
+
`Transaction blocked: ${reasonCode}`,
|
|
207
|
+
{ correlationId, requestId, details: { reasonCode, receiptId } }
|
|
208
|
+
);
|
|
209
|
+
this.name = "BlockIntelBlockedError";
|
|
210
|
+
this.receiptId = receiptId;
|
|
211
|
+
this.reasonCode = reasonCode;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
var BlockIntelUnavailableError = class extends GateError {
|
|
215
|
+
constructor(message, requestId) {
|
|
216
|
+
super("SERVICE_UNAVAILABLE" /* SERVICE_UNAVAILABLE */, message, { requestId });
|
|
217
|
+
this.name = "BlockIntelUnavailableError";
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
var BlockIntelAuthError = class extends GateError {
|
|
221
|
+
constructor(message, status, requestId) {
|
|
222
|
+
super(
|
|
223
|
+
status === 401 ? "UNAUTHORIZED" /* UNAUTHORIZED */ : "FORBIDDEN" /* FORBIDDEN */,
|
|
224
|
+
message,
|
|
225
|
+
{ status, requestId }
|
|
226
|
+
);
|
|
227
|
+
this.name = "BlockIntelAuthError";
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
var BlockIntelStepUpRequiredError = class extends GateError {
|
|
231
|
+
stepUpRequestId;
|
|
232
|
+
statusUrl;
|
|
233
|
+
expiresAtMs;
|
|
234
|
+
constructor(stepUpRequestId, statusUrl, expiresAtMs, requestId) {
|
|
235
|
+
super(
|
|
236
|
+
"STEP_UP_NOT_CONFIGURED" /* STEP_UP_NOT_CONFIGURED */,
|
|
237
|
+
"Step-up approval required",
|
|
238
|
+
{
|
|
239
|
+
requestId,
|
|
240
|
+
details: { stepUpRequestId, statusUrl, expiresAtMs }
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
this.name = "BlockIntelStepUpRequiredError";
|
|
244
|
+
this.stepUpRequestId = stepUpRequestId;
|
|
245
|
+
this.statusUrl = statusUrl;
|
|
246
|
+
this.expiresAtMs = expiresAtMs;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// src/http/retry.ts
|
|
251
|
+
var DEFAULT_RETRY_OPTIONS = {
|
|
252
|
+
maxAttempts: 3,
|
|
253
|
+
baseDelayMs: 100,
|
|
254
|
+
maxDelayMs: 800,
|
|
255
|
+
factor: 2
|
|
256
|
+
};
|
|
257
|
+
function isRetryableStatus(status) {
|
|
258
|
+
return status === 429 || status >= 500 && status < 600;
|
|
259
|
+
}
|
|
260
|
+
function isRetryableError(error) {
|
|
261
|
+
if (error instanceof Error) {
|
|
262
|
+
const message = error.message.toLowerCase();
|
|
263
|
+
return message.includes("network") || message.includes("timeout") || message.includes("connection") || message.includes("econnrefused") || message.includes("enotfound") || message.includes("econnreset");
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
function calculateBackoffDelay(attempt, options) {
|
|
268
|
+
const exponentialDelay = options.baseDelayMs * Math.pow(options.factor, attempt - 1);
|
|
269
|
+
const jitter = Math.random() * 0.3 * exponentialDelay;
|
|
270
|
+
const delay = exponentialDelay + jitter;
|
|
271
|
+
return Math.min(delay, options.maxDelayMs);
|
|
272
|
+
}
|
|
273
|
+
function isRetryableGateError(error) {
|
|
274
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
275
|
+
const gateError = error;
|
|
276
|
+
if (gateError.code === "SERVER_ERROR" || gateError.code === "RATE_LIMITED") {
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
if (gateError.status && isRetryableStatus(gateError.status)) {
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
async function retryWithBackoff(fn, options = {}) {
|
|
286
|
+
const opts = { ...DEFAULT_RETRY_OPTIONS, ...options };
|
|
287
|
+
let lastError;
|
|
288
|
+
for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
|
|
289
|
+
try {
|
|
290
|
+
return await fn();
|
|
291
|
+
} catch (error) {
|
|
292
|
+
lastError = error;
|
|
293
|
+
if (attempt >= opts.maxAttempts) {
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
if (error instanceof Response && !isRetryableStatus(error.status)) {
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
const isRetryable = error instanceof Response && isRetryableStatus(error.status) || isRetryableError(error) || isRetryableGateError(error);
|
|
300
|
+
if (!isRetryable) {
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
const delay = calculateBackoffDelay(attempt, opts);
|
|
304
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
throw lastError;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// src/http/HttpClient.ts
|
|
311
|
+
var HttpClient = class {
|
|
312
|
+
baseUrl;
|
|
313
|
+
timeoutMs;
|
|
314
|
+
userAgent;
|
|
315
|
+
retryOptions;
|
|
316
|
+
constructor(config) {
|
|
317
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
318
|
+
this.timeoutMs = config.timeoutMs ?? 15e3;
|
|
319
|
+
this.userAgent = config.userAgent ?? "blockintel-gate-sdk/0.1.0";
|
|
320
|
+
this.retryOptions = config.retryOptions;
|
|
321
|
+
if (!this.baseUrl) {
|
|
322
|
+
throw new Error("baseUrl is required");
|
|
323
|
+
}
|
|
324
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV === "production") {
|
|
325
|
+
if (!this.baseUrl.startsWith("https://") && !this.baseUrl.includes("localhost")) {
|
|
326
|
+
throw new Error("baseUrl must use HTTPS in production (except localhost)");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Make an HTTP request with retry and timeout
|
|
332
|
+
*/
|
|
333
|
+
async request(options) {
|
|
334
|
+
const { method, path, headers = {}, body, requestId } = options;
|
|
335
|
+
const url = `${this.baseUrl}${path}`;
|
|
336
|
+
const controller = new AbortController();
|
|
337
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
338
|
+
try {
|
|
339
|
+
const response = await retryWithBackoff(
|
|
340
|
+
async () => {
|
|
341
|
+
const fetchOptions = {
|
|
342
|
+
method,
|
|
343
|
+
headers: {
|
|
344
|
+
...headers,
|
|
345
|
+
"User-Agent": this.userAgent,
|
|
346
|
+
"Content-Type": "application/json"
|
|
347
|
+
},
|
|
348
|
+
signal: controller.signal
|
|
349
|
+
};
|
|
350
|
+
if (body) {
|
|
351
|
+
fetchOptions.body = JSON.stringify(body);
|
|
352
|
+
}
|
|
353
|
+
const res = await fetch(url, fetchOptions);
|
|
354
|
+
if (!res.ok && isRetryableStatus(res.status)) {
|
|
355
|
+
throw res;
|
|
356
|
+
}
|
|
357
|
+
if (!res.ok && !isRetryableStatus(res.status)) {
|
|
358
|
+
throw res;
|
|
359
|
+
}
|
|
360
|
+
return res;
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
...this.retryOptions
|
|
364
|
+
// Custom retry logic that handles Response objects
|
|
365
|
+
}
|
|
366
|
+
);
|
|
367
|
+
clearTimeout(timeoutId);
|
|
368
|
+
let data;
|
|
369
|
+
const contentType = response.headers.get("content-type");
|
|
370
|
+
if (contentType && contentType.includes("application/json")) {
|
|
371
|
+
try {
|
|
372
|
+
data = await response.json();
|
|
373
|
+
} catch (parseError) {
|
|
374
|
+
throw new GateError(
|
|
375
|
+
"INVALID_RESPONSE" /* INVALID_RESPONSE */,
|
|
376
|
+
"Failed to parse JSON response",
|
|
377
|
+
{
|
|
378
|
+
status: response.status,
|
|
379
|
+
requestId,
|
|
380
|
+
cause: parseError instanceof Error ? parseError : void 0
|
|
381
|
+
}
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
} else {
|
|
385
|
+
const text = await response.text();
|
|
386
|
+
throw new GateError(
|
|
387
|
+
"INVALID_RESPONSE" /* INVALID_RESPONSE */,
|
|
388
|
+
`Unexpected content type: ${contentType}`,
|
|
389
|
+
{
|
|
390
|
+
status: response.status,
|
|
391
|
+
details: { body: text.substring(0, 200) },
|
|
392
|
+
requestId
|
|
393
|
+
}
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
if (!response.ok) {
|
|
397
|
+
const errorCode = this.statusToErrorCode(response.status);
|
|
398
|
+
const correlationId = response.headers.get("X-Correlation-ID") ?? void 0;
|
|
399
|
+
throw new GateError(errorCode, `HTTP ${response.status}: ${response.statusText}`, {
|
|
400
|
+
status: response.status,
|
|
401
|
+
correlationId,
|
|
402
|
+
requestId,
|
|
403
|
+
details: data
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return data;
|
|
407
|
+
} catch (error) {
|
|
408
|
+
clearTimeout(timeoutId);
|
|
409
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
410
|
+
throw new GateError("TIMEOUT" /* TIMEOUT */, `Request timeout after ${this.timeoutMs}ms`, {
|
|
411
|
+
requestId
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
if (error instanceof Response) {
|
|
415
|
+
const errorCode = this.statusToErrorCode(error.status);
|
|
416
|
+
const correlationId = error.headers.get("X-Correlation-ID") ?? void 0;
|
|
417
|
+
let details;
|
|
418
|
+
try {
|
|
419
|
+
const text = await error.text();
|
|
420
|
+
try {
|
|
421
|
+
details = JSON.parse(text);
|
|
422
|
+
} catch {
|
|
423
|
+
details = { body: text.substring(0, 200) };
|
|
424
|
+
}
|
|
425
|
+
} catch {
|
|
426
|
+
}
|
|
427
|
+
throw new GateError(errorCode, `HTTP ${error.status}: ${error.statusText}`, {
|
|
428
|
+
status: error.status,
|
|
429
|
+
correlationId,
|
|
430
|
+
requestId,
|
|
431
|
+
details
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
if (isRetryableError(error)) {
|
|
435
|
+
throw new GateError(
|
|
436
|
+
"NETWORK_ERROR" /* NETWORK_ERROR */,
|
|
437
|
+
`Network error: ${error instanceof Error ? error.message : String(error)}`,
|
|
438
|
+
{
|
|
439
|
+
requestId,
|
|
440
|
+
cause: error instanceof Error ? error : void 0
|
|
441
|
+
}
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
if (error instanceof GateError) {
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
447
|
+
throw new GateError(
|
|
448
|
+
"NETWORK_ERROR" /* NETWORK_ERROR */,
|
|
449
|
+
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
|
|
450
|
+
{
|
|
451
|
+
requestId,
|
|
452
|
+
cause: error instanceof Error ? error : void 0
|
|
453
|
+
}
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Map HTTP status code to GateErrorCode
|
|
459
|
+
*/
|
|
460
|
+
statusToErrorCode(status) {
|
|
461
|
+
if (status === 401) return "UNAUTHORIZED" /* UNAUTHORIZED */;
|
|
462
|
+
if (status === 403) return "FORBIDDEN" /* FORBIDDEN */;
|
|
463
|
+
if (status === 404) return "NOT_FOUND" /* NOT_FOUND */;
|
|
464
|
+
if (status === 429) return "RATE_LIMITED" /* RATE_LIMITED */;
|
|
465
|
+
if (status >= 500 && status < 600) return "SERVER_ERROR" /* SERVER_ERROR */;
|
|
466
|
+
return "NETWORK_ERROR" /* NETWORK_ERROR */;
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// src/utils/time.ts
|
|
471
|
+
function nowMs() {
|
|
472
|
+
return Date.now();
|
|
473
|
+
}
|
|
474
|
+
function nowEpochSeconds() {
|
|
475
|
+
return Math.floor(Date.now() / 1e3);
|
|
476
|
+
}
|
|
477
|
+
function clamp(value, min, max) {
|
|
478
|
+
return Math.max(min, Math.min(max, value));
|
|
479
|
+
}
|
|
480
|
+
function sleep(ms) {
|
|
481
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// src/stepup/stepup.ts
|
|
485
|
+
var DEFAULT_POLLING_INTERVAL_MS = 250;
|
|
486
|
+
var DEFAULT_MAX_WAIT_MS = 15e3;
|
|
487
|
+
var DEFAULT_TTL_MIN_SECONDS = 300;
|
|
488
|
+
var DEFAULT_TTL_MAX_SECONDS = 900;
|
|
489
|
+
var DEFAULT_TTL_DEFAULT_SECONDS = 600;
|
|
490
|
+
var StepUpPoller = class {
|
|
491
|
+
httpClient;
|
|
492
|
+
tenantId;
|
|
493
|
+
pollingIntervalMs;
|
|
494
|
+
maxWaitMs;
|
|
495
|
+
ttlMinSeconds;
|
|
496
|
+
ttlMaxSeconds;
|
|
497
|
+
ttlDefaultSeconds;
|
|
498
|
+
constructor(config) {
|
|
499
|
+
this.httpClient = config.httpClient;
|
|
500
|
+
this.tenantId = config.tenantId;
|
|
501
|
+
this.pollingIntervalMs = config.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS;
|
|
502
|
+
this.maxWaitMs = config.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
|
|
503
|
+
this.ttlMinSeconds = config.ttlMinSeconds ?? DEFAULT_TTL_MIN_SECONDS;
|
|
504
|
+
this.ttlMaxSeconds = config.ttlMaxSeconds ?? DEFAULT_TTL_MAX_SECONDS;
|
|
505
|
+
this.ttlDefaultSeconds = config.ttlDefaultSeconds ?? DEFAULT_TTL_DEFAULT_SECONDS;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Get current step-up status
|
|
509
|
+
*/
|
|
510
|
+
async getStatus(requestId) {
|
|
511
|
+
const path = `/defense/stepup/status?tenantId=${encodeURIComponent(this.tenantId)}&requestId=${encodeURIComponent(requestId)}`;
|
|
512
|
+
try {
|
|
513
|
+
const apiResponse = await this.httpClient.request({
|
|
514
|
+
method: "GET",
|
|
515
|
+
path,
|
|
516
|
+
requestId
|
|
517
|
+
});
|
|
518
|
+
const response = {
|
|
519
|
+
status: apiResponse.status,
|
|
520
|
+
tenantId: apiResponse.tenant_id ?? apiResponse.tenantId,
|
|
521
|
+
requestId: apiResponse.request_id ?? apiResponse.requestId,
|
|
522
|
+
decision: apiResponse.decision,
|
|
523
|
+
reasonCodes: apiResponse.reason_codes ?? apiResponse.reasonCodes,
|
|
524
|
+
correlationId: apiResponse.correlation_id ?? apiResponse.correlationId,
|
|
525
|
+
expiresAtMs: apiResponse.expires_at_ms ?? apiResponse.expiresAtMs,
|
|
526
|
+
ttl: apiResponse.ttl
|
|
527
|
+
};
|
|
528
|
+
const now = nowEpochSeconds();
|
|
529
|
+
if (response.ttl !== void 0 && response.ttl <= now) {
|
|
530
|
+
return {
|
|
531
|
+
...response,
|
|
532
|
+
status: "EXPIRED"
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
return response;
|
|
536
|
+
} catch (error) {
|
|
537
|
+
if (error instanceof GateError && error.code === "NOT_FOUND" /* NOT_FOUND */) {
|
|
538
|
+
throw new GateError(
|
|
539
|
+
"NOT_FOUND" /* NOT_FOUND */,
|
|
540
|
+
`Step-up request not found: ${requestId}`,
|
|
541
|
+
{ requestId }
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
throw error;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Wait for step-up decision with polling
|
|
549
|
+
*
|
|
550
|
+
* Polls until status is APPROVED, DENIED, or EXPIRED, or timeout is reached.
|
|
551
|
+
*/
|
|
552
|
+
async awaitDecision(requestId, options) {
|
|
553
|
+
const startTime = Date.now();
|
|
554
|
+
const maxWaitMs = options?.maxWaitMs ?? this.maxWaitMs;
|
|
555
|
+
const intervalMs = options?.intervalMs ?? this.pollingIntervalMs;
|
|
556
|
+
while (true) {
|
|
557
|
+
const elapsedMs = Date.now() - startTime;
|
|
558
|
+
if (elapsedMs >= maxWaitMs) {
|
|
559
|
+
throw new GateError(
|
|
560
|
+
"STEP_UP_TIMEOUT" /* STEP_UP_TIMEOUT */,
|
|
561
|
+
`Step-up decision timeout after ${maxWaitMs}ms`,
|
|
562
|
+
{ requestId }
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
const status = await this.getStatus(requestId);
|
|
567
|
+
const now = nowEpochSeconds();
|
|
568
|
+
if (status.ttl !== void 0 && status.ttl <= now) {
|
|
569
|
+
return {
|
|
570
|
+
status: "EXPIRED",
|
|
571
|
+
requestId,
|
|
572
|
+
elapsedMs,
|
|
573
|
+
correlationId: status.correlationId
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
if (status.status === "APPROVED" || status.status === "DENIED" || status.status === "EXPIRED") {
|
|
577
|
+
return {
|
|
578
|
+
status: status.status,
|
|
579
|
+
requestId,
|
|
580
|
+
elapsedMs,
|
|
581
|
+
decision: status.decision,
|
|
582
|
+
reasonCodes: status.reasonCodes,
|
|
583
|
+
correlationId: status.correlationId
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
await sleep(intervalMs);
|
|
587
|
+
} catch (error) {
|
|
588
|
+
if (error instanceof GateError && error.code === "NOT_FOUND" /* NOT_FOUND */) {
|
|
589
|
+
throw error;
|
|
590
|
+
}
|
|
591
|
+
const remainingMs = maxWaitMs - (Date.now() - startTime);
|
|
592
|
+
if (remainingMs <= 0) {
|
|
593
|
+
throw new GateError(
|
|
594
|
+
"STEP_UP_TIMEOUT" /* STEP_UP_TIMEOUT */,
|
|
595
|
+
`Step-up decision timeout after ${maxWaitMs}ms`,
|
|
596
|
+
{ requestId, cause: error instanceof Error ? error : void 0 }
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
await sleep(Math.min(intervalMs, remainingMs));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Clamp TTL to guardrails
|
|
605
|
+
*/
|
|
606
|
+
clampTtl(ttlSeconds) {
|
|
607
|
+
if (ttlSeconds === void 0) {
|
|
608
|
+
return this.ttlDefaultSeconds;
|
|
609
|
+
}
|
|
610
|
+
return clamp(ttlSeconds, this.ttlMinSeconds, this.ttlMaxSeconds);
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
// src/circuit/CircuitBreaker.ts
|
|
615
|
+
var CircuitBreaker = class {
|
|
616
|
+
state = "CLOSED";
|
|
617
|
+
failures = 0;
|
|
618
|
+
successes = 0;
|
|
619
|
+
lastFailureTime;
|
|
620
|
+
lastSuccessTime;
|
|
621
|
+
tripsToOpen = 0;
|
|
622
|
+
tripThreshold;
|
|
623
|
+
coolDownMs;
|
|
624
|
+
constructor(config = {}) {
|
|
625
|
+
this.tripThreshold = config.tripAfterConsecutiveFailures ?? 5;
|
|
626
|
+
this.coolDownMs = config.coolDownMs ?? 3e4;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Execute function with circuit breaker protection
|
|
630
|
+
*/
|
|
631
|
+
async execute(fn) {
|
|
632
|
+
if (this.state === "OPEN") {
|
|
633
|
+
const now = Date.now();
|
|
634
|
+
const timeSinceLastFailure = this.lastFailureTime ? now - this.lastFailureTime : Infinity;
|
|
635
|
+
if (timeSinceLastFailure >= this.coolDownMs) {
|
|
636
|
+
this.state = "HALF_OPEN";
|
|
637
|
+
this.failures = 0;
|
|
638
|
+
} else {
|
|
639
|
+
throw new CircuitBreakerOpenError(
|
|
640
|
+
`Circuit breaker is OPEN. Will retry after ${this.coolDownMs - timeSinceLastFailure}ms`
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
try {
|
|
645
|
+
const result = await fn();
|
|
646
|
+
this.onSuccess();
|
|
647
|
+
return result;
|
|
648
|
+
} catch (error) {
|
|
649
|
+
this.onFailure();
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
onSuccess() {
|
|
654
|
+
this.successes++;
|
|
655
|
+
this.lastSuccessTime = Date.now();
|
|
656
|
+
if (this.state === "HALF_OPEN") {
|
|
657
|
+
this.state = "CLOSED";
|
|
658
|
+
this.failures = 0;
|
|
659
|
+
} else if (this.state === "CLOSED") {
|
|
660
|
+
this.failures = 0;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
onFailure() {
|
|
664
|
+
this.failures++;
|
|
665
|
+
this.lastFailureTime = Date.now();
|
|
666
|
+
if (this.state === "HALF_OPEN") {
|
|
667
|
+
this.state = "OPEN";
|
|
668
|
+
this.tripsToOpen++;
|
|
669
|
+
} else if (this.state === "CLOSED" && this.failures >= this.tripThreshold) {
|
|
670
|
+
this.state = "OPEN";
|
|
671
|
+
this.tripsToOpen++;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Get current metrics
|
|
676
|
+
*/
|
|
677
|
+
getMetrics() {
|
|
678
|
+
return {
|
|
679
|
+
failures: this.failures,
|
|
680
|
+
successes: this.successes,
|
|
681
|
+
state: this.state,
|
|
682
|
+
lastFailureTime: this.lastFailureTime,
|
|
683
|
+
lastSuccessTime: this.lastSuccessTime,
|
|
684
|
+
tripsToOpen: this.tripsToOpen
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Reset circuit breaker to CLOSED state
|
|
689
|
+
*/
|
|
690
|
+
reset() {
|
|
691
|
+
this.state = "CLOSED";
|
|
692
|
+
this.failures = 0;
|
|
693
|
+
this.successes = 0;
|
|
694
|
+
this.lastFailureTime = void 0;
|
|
695
|
+
this.lastSuccessTime = void 0;
|
|
696
|
+
this.tripsToOpen = 0;
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
var CircuitBreakerOpenError = class extends Error {
|
|
700
|
+
constructor(message) {
|
|
701
|
+
super(message);
|
|
702
|
+
this.name = "CircuitBreakerOpenError";
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
// src/metrics/MetricsCollector.ts
|
|
707
|
+
var MetricsCollector = class {
|
|
708
|
+
requestsTotal = 0;
|
|
709
|
+
allowedTotal = 0;
|
|
710
|
+
blockedTotal = 0;
|
|
711
|
+
stepupTotal = 0;
|
|
712
|
+
timeoutsTotal = 0;
|
|
713
|
+
errorsTotal = 0;
|
|
714
|
+
circuitBreakerOpenTotal = 0;
|
|
715
|
+
latencyMs = [];
|
|
716
|
+
maxSamples = 1e3;
|
|
717
|
+
// Keep last 1000 samples
|
|
718
|
+
hooks = [];
|
|
719
|
+
/**
|
|
720
|
+
* Record a request
|
|
721
|
+
*/
|
|
722
|
+
recordRequest(decision, latencyMs) {
|
|
723
|
+
this.requestsTotal++;
|
|
724
|
+
if (decision === "ALLOW") {
|
|
725
|
+
this.allowedTotal++;
|
|
726
|
+
} else if (decision === "BLOCK") {
|
|
727
|
+
this.blockedTotal++;
|
|
728
|
+
} else if (decision === "REQUIRE_STEP_UP") {
|
|
729
|
+
this.stepupTotal++;
|
|
730
|
+
}
|
|
731
|
+
this.latencyMs.push(latencyMs);
|
|
732
|
+
if (this.latencyMs.length > this.maxSamples) {
|
|
733
|
+
this.latencyMs.shift();
|
|
734
|
+
}
|
|
735
|
+
this.emitMetrics();
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Record a timeout
|
|
739
|
+
*/
|
|
740
|
+
recordTimeout() {
|
|
741
|
+
this.timeoutsTotal++;
|
|
742
|
+
this.errorsTotal++;
|
|
743
|
+
this.emitMetrics();
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Record an error
|
|
747
|
+
*/
|
|
748
|
+
recordError() {
|
|
749
|
+
this.errorsTotal++;
|
|
750
|
+
this.emitMetrics();
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Record circuit breaker open
|
|
754
|
+
*/
|
|
755
|
+
recordCircuitBreakerOpen() {
|
|
756
|
+
this.circuitBreakerOpenTotal++;
|
|
757
|
+
this.emitMetrics();
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Get current metrics snapshot
|
|
761
|
+
*/
|
|
762
|
+
getMetrics() {
|
|
763
|
+
return {
|
|
764
|
+
requestsTotal: this.requestsTotal,
|
|
765
|
+
allowedTotal: this.allowedTotal,
|
|
766
|
+
blockedTotal: this.blockedTotal,
|
|
767
|
+
stepupTotal: this.stepupTotal,
|
|
768
|
+
timeoutsTotal: this.timeoutsTotal,
|
|
769
|
+
errorsTotal: this.errorsTotal,
|
|
770
|
+
circuitBreakerOpenTotal: this.circuitBreakerOpenTotal,
|
|
771
|
+
latencyMs: [...this.latencyMs]
|
|
772
|
+
// Copy array
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Register a metrics hook (e.g., for Prometheus/OpenTelemetry export)
|
|
777
|
+
*/
|
|
778
|
+
registerHook(hook) {
|
|
779
|
+
this.hooks.push(hook);
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Emit metrics to all registered hooks
|
|
783
|
+
*/
|
|
784
|
+
emitMetrics() {
|
|
785
|
+
const metrics = this.getMetrics();
|
|
786
|
+
for (const hook of this.hooks) {
|
|
787
|
+
try {
|
|
788
|
+
hook(metrics);
|
|
789
|
+
} catch (error) {
|
|
790
|
+
console.error("Error in metrics hook:", error);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Reset all metrics
|
|
796
|
+
*/
|
|
797
|
+
reset() {
|
|
798
|
+
this.requestsTotal = 0;
|
|
799
|
+
this.allowedTotal = 0;
|
|
800
|
+
this.blockedTotal = 0;
|
|
801
|
+
this.stepupTotal = 0;
|
|
802
|
+
this.timeoutsTotal = 0;
|
|
803
|
+
this.errorsTotal = 0;
|
|
804
|
+
this.circuitBreakerOpenTotal = 0;
|
|
805
|
+
this.latencyMs = [];
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
function wrapKmsClient(kmsClient, gateClient, options = {}) {
|
|
809
|
+
const defaultOptions = {
|
|
810
|
+
mode: options.mode || "enforce",
|
|
811
|
+
onDecision: options.onDecision || (() => {
|
|
812
|
+
}),
|
|
813
|
+
extractTxIntent: options.extractTxIntent || defaultExtractTxIntent
|
|
814
|
+
};
|
|
815
|
+
const wrapped = new Proxy(kmsClient, {
|
|
816
|
+
get(target, prop, receiver) {
|
|
817
|
+
if (prop === "send") {
|
|
818
|
+
return async function(command) {
|
|
819
|
+
if (command && command.constructor && command.constructor.name === "SignCommand") {
|
|
820
|
+
return await handleSignCommand(
|
|
821
|
+
command,
|
|
822
|
+
target,
|
|
823
|
+
gateClient,
|
|
824
|
+
defaultOptions
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
return await target.send(command);
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
return Reflect.get(target, prop, receiver);
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
wrapped._originalClient = kmsClient;
|
|
834
|
+
wrapped._gateClient = gateClient;
|
|
835
|
+
wrapped._wrapperOptions = defaultOptions;
|
|
836
|
+
return wrapped;
|
|
837
|
+
}
|
|
838
|
+
function defaultExtractTxIntent(command) {
|
|
839
|
+
const message = command.input?.Message ?? command.Message;
|
|
840
|
+
if (!message) {
|
|
841
|
+
throw new Error("SignCommand missing required Message property");
|
|
842
|
+
}
|
|
843
|
+
const messageBuffer = message instanceof Buffer ? message : Buffer.from(message);
|
|
844
|
+
const messageHash = createHash("sha256").update(messageBuffer).digest("hex");
|
|
845
|
+
return {
|
|
846
|
+
networkFamily: "OTHER",
|
|
847
|
+
toAddress: void 0,
|
|
848
|
+
// Unknown from KMS message alone
|
|
849
|
+
payloadHash: messageHash,
|
|
850
|
+
dataHash: messageHash
|
|
851
|
+
// Backward compatibility
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
async function handleSignCommand(command, originalClient, gateClient, options) {
|
|
855
|
+
const txIntent = options.extractTxIntent(command);
|
|
856
|
+
const signerId = command.input?.KeyId ?? command.KeyId ?? "unknown";
|
|
857
|
+
const signingContext = {
|
|
858
|
+
signerId,
|
|
859
|
+
actorPrincipal: "kms-signer"
|
|
860
|
+
// Default - can be customized via extractTxIntent
|
|
861
|
+
};
|
|
862
|
+
try {
|
|
863
|
+
const decision = await gateClient.evaluate({
|
|
864
|
+
txIntent,
|
|
865
|
+
// Type assertion - txIntent may have extra fields
|
|
866
|
+
signingContext
|
|
867
|
+
});
|
|
868
|
+
options.onDecision("ALLOW", { decision, signerId, command });
|
|
869
|
+
if (options.mode === "dry-run") {
|
|
870
|
+
return await originalClient.send(new SignCommand(command));
|
|
871
|
+
}
|
|
872
|
+
return await originalClient.send(new SignCommand(command));
|
|
873
|
+
} catch (error) {
|
|
874
|
+
if (error instanceof BlockIntelBlockedError) {
|
|
875
|
+
options.onDecision("BLOCK", { error, signerId, command });
|
|
876
|
+
throw error;
|
|
877
|
+
}
|
|
878
|
+
if (error instanceof BlockIntelStepUpRequiredError) {
|
|
879
|
+
options.onDecision("REQUIRE_STEP_UP", { error, signerId, command });
|
|
880
|
+
throw error;
|
|
881
|
+
}
|
|
882
|
+
throw error;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// src/client/GateClient.ts
|
|
887
|
+
var GateClient = class {
|
|
888
|
+
config;
|
|
889
|
+
httpClient;
|
|
890
|
+
hmacSigner;
|
|
891
|
+
apiKeyAuth;
|
|
892
|
+
stepUpPoller;
|
|
893
|
+
circuitBreaker;
|
|
894
|
+
metrics;
|
|
895
|
+
constructor(config) {
|
|
896
|
+
this.config = config;
|
|
897
|
+
if (config.auth.mode === "hmac") {
|
|
898
|
+
this.hmacSigner = new HmacSigner({
|
|
899
|
+
keyId: config.auth.keyId,
|
|
900
|
+
secret: config.auth.secret
|
|
901
|
+
});
|
|
902
|
+
} else {
|
|
903
|
+
this.apiKeyAuth = new ApiKeyAuth({
|
|
904
|
+
apiKey: config.auth.apiKey
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
this.httpClient = new HttpClient({
|
|
908
|
+
baseUrl: config.baseUrl,
|
|
909
|
+
timeoutMs: config.timeoutMs,
|
|
910
|
+
userAgent: config.userAgent
|
|
911
|
+
});
|
|
912
|
+
if (config.enableStepUp) {
|
|
913
|
+
this.stepUpPoller = new StepUpPoller({
|
|
914
|
+
httpClient: this.httpClient,
|
|
915
|
+
tenantId: config.tenantId,
|
|
916
|
+
pollingIntervalMs: config.stepUp?.pollingIntervalMs,
|
|
917
|
+
maxWaitMs: config.stepUp?.maxWaitMs
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
if (config.circuitBreaker) {
|
|
921
|
+
this.circuitBreaker = new CircuitBreaker(config.circuitBreaker);
|
|
922
|
+
}
|
|
923
|
+
this.metrics = new MetricsCollector();
|
|
924
|
+
if (config.onMetrics) {
|
|
925
|
+
this.metrics.registerHook(config.onMetrics);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Evaluate a transaction defense request
|
|
930
|
+
*
|
|
931
|
+
* Implements:
|
|
932
|
+
* - Circuit breaker protection
|
|
933
|
+
* - Fail-safe modes (ALLOW_ON_TIMEOUT, BLOCK_ON_TIMEOUT, BLOCK_ON_ANOMALY)
|
|
934
|
+
* - Metrics collection
|
|
935
|
+
* - Error handling (BLOCK → BlockIntelBlockedError, REQUIRE_STEP_UP → BlockIntelStepUpRequiredError)
|
|
936
|
+
*/
|
|
937
|
+
async evaluate(req, opts) {
|
|
938
|
+
const requestId = opts?.requestId ?? v4();
|
|
939
|
+
const timestampMs = req.timestampMs ?? nowMs();
|
|
940
|
+
const startTime = Date.now();
|
|
941
|
+
const failSafeMode = this.config.failSafeMode ?? "ALLOW_ON_TIMEOUT";
|
|
942
|
+
const executeRequest = async () => {
|
|
943
|
+
const txIntent = { ...req.txIntent };
|
|
944
|
+
if (txIntent.to && !txIntent.toAddress) {
|
|
945
|
+
txIntent.toAddress = txIntent.to;
|
|
946
|
+
delete txIntent.to;
|
|
947
|
+
}
|
|
948
|
+
if (!txIntent.networkFamily && txIntent.chainId) {
|
|
949
|
+
txIntent.networkFamily = "EVM";
|
|
950
|
+
}
|
|
951
|
+
if (txIntent.from && !txIntent.fromAddress) {
|
|
952
|
+
delete txIntent.from;
|
|
953
|
+
}
|
|
954
|
+
const signingContext = {
|
|
955
|
+
...req.signingContext,
|
|
956
|
+
actorPrincipal: req.signingContext?.actorPrincipal || req.signingContext?.signerId || "unknown"
|
|
957
|
+
};
|
|
958
|
+
const body = {
|
|
959
|
+
requestId,
|
|
960
|
+
tenantId: this.config.tenantId,
|
|
961
|
+
timestampMs,
|
|
962
|
+
txIntent,
|
|
963
|
+
signingContext,
|
|
964
|
+
// Add SDK info (required by Hot Path validation)
|
|
965
|
+
sdk: {
|
|
966
|
+
name: "blockintel-gate-sdk",
|
|
967
|
+
version: "0.1.0"
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
let headers;
|
|
971
|
+
if (this.hmacSigner) {
|
|
972
|
+
const hmacHeaders = await this.hmacSigner.signRequest({
|
|
973
|
+
method: "POST",
|
|
974
|
+
path: "/defense/evaluate",
|
|
975
|
+
tenantId: this.config.tenantId,
|
|
976
|
+
timestampMs,
|
|
977
|
+
requestId,
|
|
978
|
+
body
|
|
979
|
+
});
|
|
980
|
+
headers = { ...hmacHeaders };
|
|
981
|
+
} else if (this.apiKeyAuth) {
|
|
982
|
+
const apiKeyHeaders = this.apiKeyAuth.createHeaders({
|
|
983
|
+
tenantId: this.config.tenantId,
|
|
984
|
+
timestampMs,
|
|
985
|
+
requestId
|
|
986
|
+
});
|
|
987
|
+
headers = { ...apiKeyHeaders };
|
|
988
|
+
} else {
|
|
989
|
+
throw new Error("No authentication configured");
|
|
990
|
+
}
|
|
991
|
+
const apiResponse = await this.httpClient.request({
|
|
992
|
+
method: "POST",
|
|
993
|
+
path: "/defense/evaluate",
|
|
994
|
+
headers,
|
|
995
|
+
body,
|
|
996
|
+
requestId
|
|
997
|
+
});
|
|
998
|
+
if (!apiResponse.success || !apiResponse.data) {
|
|
999
|
+
throw new GateError(
|
|
1000
|
+
"INVALID_RESPONSE" /* INVALID_RESPONSE */,
|
|
1001
|
+
"Invalid response format: expected { success: true, data: { ... } }",
|
|
1002
|
+
{
|
|
1003
|
+
requestId,
|
|
1004
|
+
details: apiResponse
|
|
1005
|
+
}
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
const responseData = apiResponse.data;
|
|
1009
|
+
const result = {
|
|
1010
|
+
decision: responseData.decision,
|
|
1011
|
+
reasonCodes: responseData.reason_codes ?? responseData.reasonCodes ?? [],
|
|
1012
|
+
policyVersion: responseData.policy_version ?? responseData.policyVersion,
|
|
1013
|
+
correlationId: responseData.correlation_id ?? responseData.correlationId,
|
|
1014
|
+
stepUp: responseData.step_up ? {
|
|
1015
|
+
requestId: responseData.step_up.request_id ?? (responseData.stepUp?.requestId ?? ""),
|
|
1016
|
+
ttlSeconds: responseData.step_up.ttl_seconds ?? responseData.stepUp?.ttlSeconds
|
|
1017
|
+
} : responseData.stepUp
|
|
1018
|
+
};
|
|
1019
|
+
const latencyMs = Date.now() - startTime;
|
|
1020
|
+
if (result.decision === "BLOCK") {
|
|
1021
|
+
const receiptId = responseData.decision_id || requestId;
|
|
1022
|
+
const reasonCode = result.reasonCodes[0] || "POLICY_VIOLATION";
|
|
1023
|
+
this.metrics.recordRequest("BLOCK", latencyMs);
|
|
1024
|
+
throw new BlockIntelBlockedError(reasonCode, receiptId, result.correlationId, requestId);
|
|
1025
|
+
}
|
|
1026
|
+
if (result.decision === "REQUIRE_STEP_UP") {
|
|
1027
|
+
if (this.config.enableStepUp && this.stepUpPoller && result.stepUp) {
|
|
1028
|
+
const stepUpRequestId = result.stepUp.requestId || requestId;
|
|
1029
|
+
const expiresAtMs = responseData.step_up?.expires_at_ms;
|
|
1030
|
+
const statusUrl = `/defense/stepup/status?tenantId=${this.config.tenantId}&requestId=${stepUpRequestId}`;
|
|
1031
|
+
this.metrics.recordRequest("REQUIRE_STEP_UP", latencyMs);
|
|
1032
|
+
throw new BlockIntelStepUpRequiredError(stepUpRequestId, statusUrl, expiresAtMs, requestId);
|
|
1033
|
+
} else {
|
|
1034
|
+
const receiptId = responseData.decision_id || requestId;
|
|
1035
|
+
const reasonCode = "STEPUP_REQUIRED";
|
|
1036
|
+
this.metrics.recordRequest("BLOCK", latencyMs);
|
|
1037
|
+
throw new BlockIntelBlockedError(reasonCode, receiptId, result.correlationId, requestId);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
this.metrics.recordRequest("ALLOW", latencyMs);
|
|
1041
|
+
return result;
|
|
1042
|
+
};
|
|
1043
|
+
try {
|
|
1044
|
+
if (this.circuitBreaker) {
|
|
1045
|
+
return await this.circuitBreaker.execute(executeRequest);
|
|
1046
|
+
}
|
|
1047
|
+
return await executeRequest();
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
if (error instanceof CircuitBreakerOpenError) {
|
|
1050
|
+
this.metrics.recordCircuitBreakerOpen();
|
|
1051
|
+
const failSafeResult = this.handleFailSafe(failSafeMode, error, requestId);
|
|
1052
|
+
if (failSafeResult) {
|
|
1053
|
+
return failSafeResult;
|
|
1054
|
+
}
|
|
1055
|
+
throw error;
|
|
1056
|
+
}
|
|
1057
|
+
if (error instanceof GateError && (error.code === "UNAUTHORIZED" /* UNAUTHORIZED */ || error.code === "FORBIDDEN" /* FORBIDDEN */)) {
|
|
1058
|
+
this.metrics.recordError();
|
|
1059
|
+
throw new BlockIntelAuthError(
|
|
1060
|
+
error.message,
|
|
1061
|
+
error.status || 401,
|
|
1062
|
+
requestId
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
if (error instanceof GateError && error.code === "TIMEOUT" /* TIMEOUT */) {
|
|
1066
|
+
this.metrics.recordTimeout();
|
|
1067
|
+
const failSafeResult = this.handleFailSafe(failSafeMode, error, requestId);
|
|
1068
|
+
if (failSafeResult) {
|
|
1069
|
+
return failSafeResult;
|
|
1070
|
+
}
|
|
1071
|
+
throw new BlockIntelUnavailableError(`Service timeout: ${error.message}`, requestId);
|
|
1072
|
+
}
|
|
1073
|
+
if (error instanceof GateError && error.code === "SERVER_ERROR" /* SERVER_ERROR */) {
|
|
1074
|
+
this.metrics.recordError();
|
|
1075
|
+
const failSafeResult = this.handleFailSafe(failSafeMode, error, requestId);
|
|
1076
|
+
if (failSafeResult) {
|
|
1077
|
+
return failSafeResult;
|
|
1078
|
+
}
|
|
1079
|
+
throw error;
|
|
1080
|
+
}
|
|
1081
|
+
if (error instanceof BlockIntelBlockedError || error instanceof BlockIntelStepUpRequiredError) {
|
|
1082
|
+
throw error;
|
|
1083
|
+
}
|
|
1084
|
+
this.metrics.recordError();
|
|
1085
|
+
throw error;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Handle fail-safe modes for timeouts/errors
|
|
1090
|
+
*/
|
|
1091
|
+
handleFailSafe(mode, error, requestId) {
|
|
1092
|
+
if (mode === "ALLOW_ON_TIMEOUT") {
|
|
1093
|
+
return {
|
|
1094
|
+
decision: "ALLOW",
|
|
1095
|
+
reasonCodes: ["FAIL_SAFE_ALLOW"],
|
|
1096
|
+
correlationId: requestId
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
if (mode === "BLOCK_ON_TIMEOUT") {
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
if (mode === "BLOCK_ON_ANOMALY") {
|
|
1103
|
+
return {
|
|
1104
|
+
decision: "ALLOW",
|
|
1105
|
+
reasonCodes: ["FAIL_SAFE_ALLOW"],
|
|
1106
|
+
correlationId: requestId
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
return null;
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Get current metrics
|
|
1113
|
+
*/
|
|
1114
|
+
getMetrics() {
|
|
1115
|
+
return this.metrics.getMetrics();
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Get circuit breaker metrics (if enabled)
|
|
1119
|
+
*/
|
|
1120
|
+
getCircuitBreakerMetrics() {
|
|
1121
|
+
return this.circuitBreaker?.getMetrics() || null;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Get step-up status
|
|
1125
|
+
*/
|
|
1126
|
+
async getStepUpStatus(args) {
|
|
1127
|
+
if (!this.stepUpPoller) {
|
|
1128
|
+
throw new StepUpNotConfiguredError(args.requestId);
|
|
1129
|
+
}
|
|
1130
|
+
const tenantId = args.tenantId ?? this.config.tenantId;
|
|
1131
|
+
const poller = new StepUpPoller({
|
|
1132
|
+
httpClient: this.httpClient,
|
|
1133
|
+
tenantId,
|
|
1134
|
+
pollingIntervalMs: this.config.stepUp?.pollingIntervalMs,
|
|
1135
|
+
maxWaitMs: this.config.stepUp?.maxWaitMs
|
|
1136
|
+
});
|
|
1137
|
+
return poller.getStatus(args.requestId);
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Wait for step-up decision with polling
|
|
1141
|
+
*/
|
|
1142
|
+
async awaitStepUpDecision(args) {
|
|
1143
|
+
if (!this.stepUpPoller) {
|
|
1144
|
+
throw new StepUpNotConfiguredError(args.requestId);
|
|
1145
|
+
}
|
|
1146
|
+
return this.stepUpPoller.awaitDecision(args.requestId, {
|
|
1147
|
+
maxWaitMs: args.maxWaitMs ?? this.config.stepUp?.maxWaitMs,
|
|
1148
|
+
intervalMs: args.intervalMs ?? this.config.stepUp?.pollingIntervalMs
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Wrap AWS SDK v3 KMS client to intercept SignCommand calls
|
|
1153
|
+
*
|
|
1154
|
+
* @param kmsClient - AWS SDK v3 KMSClient instance
|
|
1155
|
+
* @param options - Wrapper options
|
|
1156
|
+
* @returns Wrapped KMS client that enforces Gate policies
|
|
1157
|
+
*
|
|
1158
|
+
* @example
|
|
1159
|
+
* ```typescript
|
|
1160
|
+
* import { KMSClient } from '@aws-sdk/client-kms';
|
|
1161
|
+
*
|
|
1162
|
+
* const kms = new KMSClient({});
|
|
1163
|
+
* const protectedKms = gateClient.wrapKmsClient(kms);
|
|
1164
|
+
*
|
|
1165
|
+
* // Now SignCommand calls will be intercepted and evaluated by Gate
|
|
1166
|
+
* const result = await protectedKms.send(new SignCommand({ ... }));
|
|
1167
|
+
* ```
|
|
1168
|
+
*/
|
|
1169
|
+
wrapKmsClient(kmsClient, options) {
|
|
1170
|
+
return wrapKmsClient(kmsClient, this, options);
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
function createGateClient(config) {
|
|
1174
|
+
return new GateClient(config);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
export { BlockIntelAuthError, BlockIntelBlockedError, BlockIntelStepUpRequiredError, BlockIntelUnavailableError, GateClient, GateError, GateErrorCode, StepUpNotConfiguredError, createGateClient, GateClient as default, wrapKmsClient };
|
|
1178
|
+
//# sourceMappingURL=index.js.map
|
|
1179
|
+
//# sourceMappingURL=index.js.map
|