blockintel-gate-sdk 0.3.7 → 0.3.9
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/README.md +2 -0
- package/dist/contracts-KKk945Ox.d.cts +380 -0
- package/dist/contracts-KKk945Ox.d.ts +380 -0
- package/dist/index.cjs +1020 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +392 -298
- package/dist/index.d.ts +392 -298
- package/dist/index.js +1015 -4
- package/dist/index.js.map +1 -1
- package/dist/pilot/index.cjs +2401 -0
- package/dist/pilot/index.cjs.map +1 -0
- package/dist/pilot/index.d.cts +38 -0
- package/dist/pilot/index.d.ts +38 -0
- package/dist/pilot/index.js +2397 -0
- package/dist/pilot/index.js.map +1 -0
- package/package.json +14 -1
|
@@ -0,0 +1,2397 @@
|
|
|
1
|
+
import { createHash, createVerify, createHmac } from 'crypto';
|
|
2
|
+
import { v4 } from 'uuid';
|
|
3
|
+
import { SignCommand } from '@aws-sdk/client-kms';
|
|
4
|
+
|
|
5
|
+
var __defProp = Object.defineProperty;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __esm = (fn, res) => function __init() {
|
|
8
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
9
|
+
};
|
|
10
|
+
var __export = (target, all) => {
|
|
11
|
+
for (var name in all)
|
|
12
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// src/utils/canonicalJson.ts
|
|
16
|
+
var canonicalJson_exports = {};
|
|
17
|
+
__export(canonicalJson_exports, {
|
|
18
|
+
canonicalizeJson: () => canonicalizeJson,
|
|
19
|
+
sha256Hex: () => sha256Hex
|
|
20
|
+
});
|
|
21
|
+
function canonicalizeJson(obj) {
|
|
22
|
+
if (obj === null || obj === void 0) {
|
|
23
|
+
return "null";
|
|
24
|
+
}
|
|
25
|
+
const cloned = JSON.parse(JSON.stringify(obj));
|
|
26
|
+
function sortKeys(item) {
|
|
27
|
+
if (Array.isArray(item)) {
|
|
28
|
+
return item.map(sortKeys);
|
|
29
|
+
}
|
|
30
|
+
if (item !== null && typeof item === "object") {
|
|
31
|
+
const sorted2 = {};
|
|
32
|
+
Object.keys(item).sort().forEach((key) => {
|
|
33
|
+
sorted2[key] = sortKeys(item[key]);
|
|
34
|
+
});
|
|
35
|
+
return sorted2;
|
|
36
|
+
}
|
|
37
|
+
return item;
|
|
38
|
+
}
|
|
39
|
+
const sorted = sortKeys(cloned);
|
|
40
|
+
return JSON.stringify(sorted);
|
|
41
|
+
}
|
|
42
|
+
async function sha256Hex(input) {
|
|
43
|
+
return createHash("sha256").update(input, "utf8").digest("hex");
|
|
44
|
+
}
|
|
45
|
+
var init_canonicalJson = __esm({
|
|
46
|
+
"src/utils/canonicalJson.ts"() {
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// src/utils/decisionTokenVerify.ts
|
|
51
|
+
var decisionTokenVerify_exports = {};
|
|
52
|
+
__export(decisionTokenVerify_exports, {
|
|
53
|
+
decodeJwtUnsafe: () => decodeJwtUnsafe,
|
|
54
|
+
verifyDecisionTokenRs256: () => verifyDecisionTokenRs256
|
|
55
|
+
});
|
|
56
|
+
function decodeJwtUnsafe(token) {
|
|
57
|
+
try {
|
|
58
|
+
const parts = token.split(".");
|
|
59
|
+
if (parts.length !== 3) return null;
|
|
60
|
+
const header = JSON.parse(
|
|
61
|
+
Buffer.from(parts[0], "base64url").toString("utf8")
|
|
62
|
+
);
|
|
63
|
+
const payload = JSON.parse(
|
|
64
|
+
Buffer.from(parts[1], "base64url").toString("utf8")
|
|
65
|
+
);
|
|
66
|
+
return { header, payload };
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function verifyDecisionTokenRs256(token, publicKeyPem) {
|
|
72
|
+
const decoded = decodeJwtUnsafe(token);
|
|
73
|
+
if (!decoded || (decoded.header.alg || "").toUpperCase() !== "RS256") return null;
|
|
74
|
+
const { payload } = decoded;
|
|
75
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
76
|
+
if (payload.iss !== ISS || payload.aud !== AUD) return null;
|
|
77
|
+
if (payload.exp != null && payload.exp < now - 5) return null;
|
|
78
|
+
try {
|
|
79
|
+
const parts = token.split(".");
|
|
80
|
+
const signingInput = `${parts[0]}.${parts[1]}`;
|
|
81
|
+
const signature = Buffer.from(parts[2], "base64url");
|
|
82
|
+
const verify = createVerify("RSA-SHA256");
|
|
83
|
+
verify.update(signingInput);
|
|
84
|
+
verify.end();
|
|
85
|
+
const ok = verify.verify(publicKeyPem, signature);
|
|
86
|
+
return ok ? payload : null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
var ISS, AUD;
|
|
92
|
+
var init_decisionTokenVerify = __esm({
|
|
93
|
+
"src/utils/decisionTokenVerify.ts"() {
|
|
94
|
+
ISS = "blockintel-gate";
|
|
95
|
+
AUD = "gate-decision";
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
async function hmacSha256(secret, message) {
|
|
99
|
+
const hmac = createHmac("sha256", secret);
|
|
100
|
+
hmac.update(message, "utf8");
|
|
101
|
+
const signatureHex = hmac.digest("hex");
|
|
102
|
+
console.error("[HMAC CRYPTO DEBUG] Signature computation:", JSON.stringify({
|
|
103
|
+
secretLength: secret.length,
|
|
104
|
+
messageLength: message.length,
|
|
105
|
+
messagePreview: message.substring(0, 200) + "...",
|
|
106
|
+
signatureLength: signatureHex.length,
|
|
107
|
+
signaturePreview: signatureHex.substring(0, 16) + "..."
|
|
108
|
+
}, null, 2));
|
|
109
|
+
return signatureHex;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/auth/HmacSigner.ts
|
|
113
|
+
init_canonicalJson();
|
|
114
|
+
var HmacSigner = class {
|
|
115
|
+
keyId;
|
|
116
|
+
secret;
|
|
117
|
+
constructor(config) {
|
|
118
|
+
this.keyId = config.keyId;
|
|
119
|
+
this.secret = config.secret.trim();
|
|
120
|
+
if (!this.secret || this.secret.length === 0) {
|
|
121
|
+
throw new Error("HMAC secret cannot be empty");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Sign a request and return headers
|
|
126
|
+
*/
|
|
127
|
+
async signRequest(params) {
|
|
128
|
+
const { method, path, tenantId, timestampMs, requestId, body } = params;
|
|
129
|
+
const bodyJson = body ? canonicalizeJson(body) : "";
|
|
130
|
+
const bodyHash = await sha256Hex(bodyJson);
|
|
131
|
+
const signingString = [
|
|
132
|
+
"v1",
|
|
133
|
+
method.toUpperCase(),
|
|
134
|
+
path,
|
|
135
|
+
tenantId,
|
|
136
|
+
this.keyId,
|
|
137
|
+
String(timestampMs),
|
|
138
|
+
requestId,
|
|
139
|
+
// Used as nonce in canonical string
|
|
140
|
+
bodyHash
|
|
141
|
+
].join("\n");
|
|
142
|
+
const signature = await hmacSha256(this.secret, signingString);
|
|
143
|
+
return {
|
|
144
|
+
"X-GATE-TENANT-ID": tenantId,
|
|
145
|
+
"X-GATE-KEY-ID": this.keyId,
|
|
146
|
+
"X-GATE-TIMESTAMP-MS": String(timestampMs),
|
|
147
|
+
"X-GATE-REQUEST-ID": requestId,
|
|
148
|
+
"X-GATE-SIGNATURE": signature
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// src/auth/ApiKeyAuth.ts
|
|
154
|
+
var ApiKeyAuth = class {
|
|
155
|
+
apiKey;
|
|
156
|
+
constructor(config) {
|
|
157
|
+
this.apiKey = config.apiKey;
|
|
158
|
+
if (!this.apiKey || this.apiKey.length === 0) {
|
|
159
|
+
throw new Error("API key cannot be empty");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Create headers for API key authentication
|
|
164
|
+
*/
|
|
165
|
+
createHeaders(params) {
|
|
166
|
+
const { tenantId, timestampMs, requestId } = params;
|
|
167
|
+
return {
|
|
168
|
+
"X-API-KEY": this.apiKey,
|
|
169
|
+
"X-GATE-TENANT-ID": tenantId,
|
|
170
|
+
"X-GATE-REQUEST-ID": requestId,
|
|
171
|
+
"X-GATE-TIMESTAMP-MS": String(timestampMs)
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// src/types/errors.ts
|
|
177
|
+
var GateError = class extends Error {
|
|
178
|
+
code;
|
|
179
|
+
status;
|
|
180
|
+
details;
|
|
181
|
+
requestId;
|
|
182
|
+
correlationId;
|
|
183
|
+
constructor(code, message, options) {
|
|
184
|
+
super(message);
|
|
185
|
+
this.name = "GateError";
|
|
186
|
+
this.code = code;
|
|
187
|
+
this.status = options?.status;
|
|
188
|
+
this.details = options?.details;
|
|
189
|
+
this.requestId = options?.requestId;
|
|
190
|
+
this.correlationId = options?.correlationId;
|
|
191
|
+
if (options?.cause) {
|
|
192
|
+
this.cause = options.cause;
|
|
193
|
+
}
|
|
194
|
+
Error.captureStackTrace(this, this.constructor);
|
|
195
|
+
}
|
|
196
|
+
toJSON() {
|
|
197
|
+
return {
|
|
198
|
+
name: this.name,
|
|
199
|
+
code: this.code,
|
|
200
|
+
message: this.message,
|
|
201
|
+
status: this.status,
|
|
202
|
+
details: this.details,
|
|
203
|
+
requestId: this.requestId,
|
|
204
|
+
correlationId: this.correlationId
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
var StepUpNotConfiguredError = class extends GateError {
|
|
209
|
+
constructor(requestId) {
|
|
210
|
+
super(
|
|
211
|
+
"STEP_UP_NOT_CONFIGURED" /* STEP_UP_NOT_CONFIGURED */,
|
|
212
|
+
"Step-up is required but not configured in SDK. Enable step-up in client config or treat REQUIRE_STEP_UP as BLOCK.",
|
|
213
|
+
{ requestId }
|
|
214
|
+
);
|
|
215
|
+
this.name = "StepUpNotConfiguredError";
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
var BlockIntelBlockedError = class extends GateError {
|
|
219
|
+
receiptId;
|
|
220
|
+
reasonCode;
|
|
221
|
+
constructor(reasonCode, receiptId, correlationId, requestId) {
|
|
222
|
+
super(
|
|
223
|
+
"BLOCKED" /* BLOCKED */,
|
|
224
|
+
`Transaction blocked: ${reasonCode}`,
|
|
225
|
+
{ correlationId, requestId, details: { reasonCode, receiptId } }
|
|
226
|
+
);
|
|
227
|
+
this.name = "BlockIntelBlockedError";
|
|
228
|
+
this.receiptId = receiptId;
|
|
229
|
+
this.reasonCode = reasonCode;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
var BlockIntelUnavailableError = class extends GateError {
|
|
233
|
+
constructor(message, requestId) {
|
|
234
|
+
super("SERVICE_UNAVAILABLE" /* SERVICE_UNAVAILABLE */, message, { requestId });
|
|
235
|
+
this.name = "BlockIntelUnavailableError";
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
var BlockIntelAuthError = class extends GateError {
|
|
239
|
+
constructor(message, status, requestId) {
|
|
240
|
+
super(
|
|
241
|
+
status === 401 ? "UNAUTHORIZED" /* UNAUTHORIZED */ : "FORBIDDEN" /* FORBIDDEN */,
|
|
242
|
+
message,
|
|
243
|
+
{ status, requestId }
|
|
244
|
+
);
|
|
245
|
+
this.name = "BlockIntelAuthError";
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
var BlockIntelStepUpRequiredError = class extends GateError {
|
|
249
|
+
stepUpRequestId;
|
|
250
|
+
statusUrl;
|
|
251
|
+
expiresAtMs;
|
|
252
|
+
constructor(stepUpRequestId, statusUrl, expiresAtMs, requestId) {
|
|
253
|
+
super(
|
|
254
|
+
"STEP_UP_NOT_CONFIGURED" /* STEP_UP_NOT_CONFIGURED */,
|
|
255
|
+
"Step-up approval required",
|
|
256
|
+
{
|
|
257
|
+
requestId,
|
|
258
|
+
details: { stepUpRequestId, statusUrl, expiresAtMs }
|
|
259
|
+
}
|
|
260
|
+
);
|
|
261
|
+
this.name = "BlockIntelStepUpRequiredError";
|
|
262
|
+
this.stepUpRequestId = stepUpRequestId;
|
|
263
|
+
this.statusUrl = statusUrl;
|
|
264
|
+
this.expiresAtMs = expiresAtMs;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// src/http/retry.ts
|
|
269
|
+
var DEFAULT_RETRY_OPTIONS = {
|
|
270
|
+
maxAttempts: 3,
|
|
271
|
+
baseDelayMs: 100,
|
|
272
|
+
maxDelayMs: 800,
|
|
273
|
+
factor: 2
|
|
274
|
+
};
|
|
275
|
+
function isRetryableStatus(status) {
|
|
276
|
+
return status === 429 || status >= 500 && status < 600;
|
|
277
|
+
}
|
|
278
|
+
function isRetryableError(error) {
|
|
279
|
+
if (error instanceof Error) {
|
|
280
|
+
const message = error.message.toLowerCase();
|
|
281
|
+
return message.includes("network") || message.includes("timeout") || message.includes("connection") || message.includes("econnrefused") || message.includes("enotfound") || message.includes("econnreset");
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
function calculateBackoffDelay(attempt, options) {
|
|
286
|
+
const exponentialDelay = options.baseDelayMs * Math.pow(options.factor, attempt - 1);
|
|
287
|
+
const jitter = Math.random() * 0.3 * exponentialDelay;
|
|
288
|
+
const delay = exponentialDelay + jitter;
|
|
289
|
+
return Math.min(delay, options.maxDelayMs);
|
|
290
|
+
}
|
|
291
|
+
function isRetryableGateError(error) {
|
|
292
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
293
|
+
const gateError = error;
|
|
294
|
+
if (gateError.code === "SERVER_ERROR" || gateError.code === "RATE_LIMITED") {
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
if (gateError.status && isRetryableStatus(gateError.status)) {
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
async function retryWithBackoff(fn, options = {}) {
|
|
304
|
+
const opts = { ...DEFAULT_RETRY_OPTIONS, ...options };
|
|
305
|
+
let lastError;
|
|
306
|
+
for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
|
|
307
|
+
try {
|
|
308
|
+
return await fn();
|
|
309
|
+
} catch (error) {
|
|
310
|
+
lastError = error;
|
|
311
|
+
if (attempt >= opts.maxAttempts) {
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
if (error instanceof Response && !isRetryableStatus(error.status)) {
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
const isRetryable = error instanceof Response && isRetryableStatus(error.status) || isRetryableError(error) || isRetryableGateError(error);
|
|
318
|
+
if (!isRetryable) {
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
const status = error instanceof Response && error.status || error && typeof error === "object" && "status" in error && error.status || error && typeof error === "object" && "statusCode" in error && error.statusCode;
|
|
322
|
+
const errName = error instanceof Error ? error.name : error && typeof error === "object" && "code" in error ? error.code : "Unknown";
|
|
323
|
+
const extra = ` attempt=${attempt}/${opts.maxAttempts} status=${status ?? "n/a"} err=${errName}`;
|
|
324
|
+
console.warn("[GATE SDK] X-BlockIntel-Degraded: true (reason=retry)" + extra);
|
|
325
|
+
const delay = calculateBackoffDelay(attempt, opts);
|
|
326
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
throw lastError;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/utils/sanitize.ts
|
|
333
|
+
var SENSITIVE_HEADER_NAMES = /* @__PURE__ */ new Set([
|
|
334
|
+
"authorization",
|
|
335
|
+
"x-api-key",
|
|
336
|
+
"x-gate-heartbeat-key",
|
|
337
|
+
"x-gate-signature",
|
|
338
|
+
"cookie"
|
|
339
|
+
]);
|
|
340
|
+
var MAX_STRING_LENGTH = 80;
|
|
341
|
+
function sanitizeHeaders(headers) {
|
|
342
|
+
const out = {};
|
|
343
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
344
|
+
const lower = key.toLowerCase();
|
|
345
|
+
if (SENSITIVE_HEADER_NAMES.has(lower) || lower.includes("signature") || lower.includes("secret") || lower.includes("token")) {
|
|
346
|
+
out[key] = value ? "[REDACTED]" : "[empty]";
|
|
347
|
+
} else {
|
|
348
|
+
out[key] = truncate(String(value), MAX_STRING_LENGTH);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
function sanitizeBodyShape(body) {
|
|
354
|
+
if (body === null || body === void 0) {
|
|
355
|
+
return {};
|
|
356
|
+
}
|
|
357
|
+
if (typeof body !== "object") {
|
|
358
|
+
return { _: typeof body };
|
|
359
|
+
}
|
|
360
|
+
if (Array.isArray(body)) {
|
|
361
|
+
return { _: "array", length: String(body.length) };
|
|
362
|
+
}
|
|
363
|
+
const out = {};
|
|
364
|
+
for (const key of Object.keys(body).sort()) {
|
|
365
|
+
const val = body[key];
|
|
366
|
+
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
|
|
367
|
+
out[key] = "object";
|
|
368
|
+
} else if (Array.isArray(val)) {
|
|
369
|
+
out[key] = "array";
|
|
370
|
+
} else {
|
|
371
|
+
out[key] = typeof val;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
function truncate(s, max) {
|
|
377
|
+
if (s.length <= max) return s;
|
|
378
|
+
return s.slice(0, max) + "...";
|
|
379
|
+
}
|
|
380
|
+
function isDebugEnabled(debugOption) {
|
|
381
|
+
if (debugOption === true) return true;
|
|
382
|
+
if (typeof process !== "undefined" && process.env.GATE_SDK_DEBUG === "1") return true;
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/http/HttpClient.ts
|
|
387
|
+
var HttpClient = class {
|
|
388
|
+
baseUrl;
|
|
389
|
+
timeoutMs;
|
|
390
|
+
userAgent;
|
|
391
|
+
retryOptions;
|
|
392
|
+
debug;
|
|
393
|
+
constructor(config) {
|
|
394
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
395
|
+
this.timeoutMs = config.timeoutMs ?? 15e3;
|
|
396
|
+
this.userAgent = config.userAgent ?? "blockintel-gate-sdk/0.1.0";
|
|
397
|
+
this.retryOptions = config.retryOptions;
|
|
398
|
+
this.debug = isDebugEnabled(config.debug);
|
|
399
|
+
if (!this.baseUrl) {
|
|
400
|
+
throw new Error("baseUrl is required");
|
|
401
|
+
}
|
|
402
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV === "production") {
|
|
403
|
+
if (!this.baseUrl.startsWith("https://") && !this.baseUrl.includes("localhost")) {
|
|
404
|
+
throw new Error("baseUrl must use HTTPS in production (except localhost)");
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Make an HTTP request with retry and timeout
|
|
410
|
+
*/
|
|
411
|
+
async request(options) {
|
|
412
|
+
const { method, path, headers = {}, body, requestId } = options;
|
|
413
|
+
const url = `${this.baseUrl}${path}`;
|
|
414
|
+
const controller = new AbortController();
|
|
415
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
416
|
+
let requestDetailsForLogging = null;
|
|
417
|
+
try {
|
|
418
|
+
const response = await retryWithBackoff(
|
|
419
|
+
async () => {
|
|
420
|
+
const requestHeaders = {};
|
|
421
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
422
|
+
requestHeaders[key] = String(value);
|
|
423
|
+
}
|
|
424
|
+
requestHeaders["User-Agent"] = this.userAgent;
|
|
425
|
+
requestHeaders["Content-Type"] = "application/json";
|
|
426
|
+
const fetchOptions = {
|
|
427
|
+
method,
|
|
428
|
+
headers: requestHeaders,
|
|
429
|
+
signal: controller.signal
|
|
430
|
+
};
|
|
431
|
+
if (body) {
|
|
432
|
+
if (body.__canonicalJson) {
|
|
433
|
+
fetchOptions.body = body.__canonicalJson;
|
|
434
|
+
delete body.__canonicalJson;
|
|
435
|
+
} else {
|
|
436
|
+
fetchOptions.body = JSON.stringify(body);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const bodyStr = typeof fetchOptions.body === "string" ? fetchOptions.body : null;
|
|
440
|
+
requestDetailsForLogging = {
|
|
441
|
+
headers: this.debug ? sanitizeHeaders(requestHeaders) : {},
|
|
442
|
+
bodyLength: bodyStr ? bodyStr.length : 0
|
|
443
|
+
};
|
|
444
|
+
if (this.debug) {
|
|
445
|
+
const bodyShape = body && typeof body === "object" ? sanitizeBodyShape(body) : {};
|
|
446
|
+
console.error("[GATE SDK] Request:", JSON.stringify({
|
|
447
|
+
url,
|
|
448
|
+
method,
|
|
449
|
+
headerNames: Object.keys(requestHeaders),
|
|
450
|
+
headersRedacted: requestDetailsForLogging.headers,
|
|
451
|
+
bodyLength: requestDetailsForLogging.bodyLength,
|
|
452
|
+
bodyKeysAndTypes: bodyShape
|
|
453
|
+
}, null, 2));
|
|
454
|
+
}
|
|
455
|
+
const res = await fetch(url, fetchOptions);
|
|
456
|
+
if (!res.ok && isRetryableStatus(res.status)) {
|
|
457
|
+
throw res;
|
|
458
|
+
}
|
|
459
|
+
if (!res.ok && !isRetryableStatus(res.status)) {
|
|
460
|
+
throw res;
|
|
461
|
+
}
|
|
462
|
+
return res;
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
...this.retryOptions
|
|
466
|
+
// Custom retry logic that handles Response objects
|
|
467
|
+
}
|
|
468
|
+
);
|
|
469
|
+
clearTimeout(timeoutId);
|
|
470
|
+
let data;
|
|
471
|
+
const contentType = response.headers.get("content-type");
|
|
472
|
+
if (this.debug) {
|
|
473
|
+
console.error("[GATE SDK] Response:", JSON.stringify({
|
|
474
|
+
status: response.status,
|
|
475
|
+
ok: response.ok,
|
|
476
|
+
url: response.url
|
|
477
|
+
}, null, 2));
|
|
478
|
+
}
|
|
479
|
+
if (contentType && contentType.includes("application/json")) {
|
|
480
|
+
try {
|
|
481
|
+
const jsonText = await response.text();
|
|
482
|
+
data = JSON.parse(jsonText);
|
|
483
|
+
if (this.debug && data && typeof data === "object") {
|
|
484
|
+
console.error("[GATE SDK] Response keys:", Object.keys(data));
|
|
485
|
+
}
|
|
486
|
+
} catch (parseError) {
|
|
487
|
+
if (this.debug) {
|
|
488
|
+
console.error("[GATE SDK] JSON parse error:", parseError instanceof Error ? parseError.message : String(parseError));
|
|
489
|
+
}
|
|
490
|
+
throw new GateError(
|
|
491
|
+
"INVALID_RESPONSE" /* INVALID_RESPONSE */,
|
|
492
|
+
"Failed to parse JSON response",
|
|
493
|
+
{
|
|
494
|
+
status: response.status,
|
|
495
|
+
requestId,
|
|
496
|
+
cause: parseError instanceof Error ? parseError : void 0
|
|
497
|
+
}
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
const text = await response.text();
|
|
502
|
+
throw new GateError(
|
|
503
|
+
"INVALID_RESPONSE" /* INVALID_RESPONSE */,
|
|
504
|
+
`Unexpected content type: ${contentType}`,
|
|
505
|
+
{
|
|
506
|
+
status: response.status,
|
|
507
|
+
details: { body: text.substring(0, 200) },
|
|
508
|
+
requestId
|
|
509
|
+
}
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
if (!response.ok) {
|
|
513
|
+
const responseHeaders = {};
|
|
514
|
+
response.headers.forEach((value, key) => {
|
|
515
|
+
responseHeaders[key] = value;
|
|
516
|
+
});
|
|
517
|
+
if (this.debug) {
|
|
518
|
+
console.error("[GATE SDK] Error response:", JSON.stringify({
|
|
519
|
+
status: response.status,
|
|
520
|
+
url: response.url,
|
|
521
|
+
requestPath: path,
|
|
522
|
+
responseKeys: data && typeof data === "object" ? Object.keys(data) : []
|
|
523
|
+
}, null, 2));
|
|
524
|
+
}
|
|
525
|
+
const errorCode = this.statusToErrorCode(response.status);
|
|
526
|
+
const correlationId = response.headers.get("X-Correlation-ID") ?? void 0;
|
|
527
|
+
throw new GateError(errorCode, `HTTP ${response.status}: ${response.statusText}`, {
|
|
528
|
+
status: response.status,
|
|
529
|
+
correlationId,
|
|
530
|
+
requestId,
|
|
531
|
+
details: data
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
return data;
|
|
535
|
+
} catch (error) {
|
|
536
|
+
clearTimeout(timeoutId);
|
|
537
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
538
|
+
throw new GateError("TIMEOUT" /* TIMEOUT */, `Request timeout after ${this.timeoutMs}ms`, {
|
|
539
|
+
requestId
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
if (error instanceof Response) {
|
|
543
|
+
const errorCode = this.statusToErrorCode(error.status);
|
|
544
|
+
const correlationId = error.headers.get("X-Correlation-ID") ?? void 0;
|
|
545
|
+
let details;
|
|
546
|
+
try {
|
|
547
|
+
const text = await error.text();
|
|
548
|
+
try {
|
|
549
|
+
details = JSON.parse(text);
|
|
550
|
+
} catch {
|
|
551
|
+
details = { body: text.substring(0, 200) };
|
|
552
|
+
}
|
|
553
|
+
} catch {
|
|
554
|
+
}
|
|
555
|
+
throw new GateError(errorCode, `HTTP ${error.status}: ${error.statusText}`, {
|
|
556
|
+
status: error.status,
|
|
557
|
+
correlationId,
|
|
558
|
+
requestId,
|
|
559
|
+
details
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
if (isRetryableError(error)) {
|
|
563
|
+
throw new GateError(
|
|
564
|
+
"NETWORK_ERROR" /* NETWORK_ERROR */,
|
|
565
|
+
`Network error: ${error instanceof Error ? error.message : String(error)}`,
|
|
566
|
+
{
|
|
567
|
+
requestId,
|
|
568
|
+
cause: error instanceof Error ? error : void 0
|
|
569
|
+
}
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
if (error instanceof GateError) {
|
|
573
|
+
throw error;
|
|
574
|
+
}
|
|
575
|
+
throw new GateError(
|
|
576
|
+
"NETWORK_ERROR" /* NETWORK_ERROR */,
|
|
577
|
+
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
|
|
578
|
+
{
|
|
579
|
+
requestId,
|
|
580
|
+
cause: error instanceof Error ? error : void 0
|
|
581
|
+
}
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Map HTTP status code to GateErrorCode
|
|
587
|
+
*/
|
|
588
|
+
statusToErrorCode(status) {
|
|
589
|
+
if (status === 401) return "UNAUTHORIZED" /* UNAUTHORIZED */;
|
|
590
|
+
if (status === 403) return "FORBIDDEN" /* FORBIDDEN */;
|
|
591
|
+
if (status === 404) return "NOT_FOUND" /* NOT_FOUND */;
|
|
592
|
+
if (status === 429) return "RATE_LIMITED" /* RATE_LIMITED */;
|
|
593
|
+
if (status >= 500 && status < 600) return "SERVER_ERROR" /* SERVER_ERROR */;
|
|
594
|
+
return "NETWORK_ERROR" /* NETWORK_ERROR */;
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
// src/utils/time.ts
|
|
599
|
+
function nowMs() {
|
|
600
|
+
return Date.now();
|
|
601
|
+
}
|
|
602
|
+
function nowEpochSeconds() {
|
|
603
|
+
return Math.floor(Date.now() / 1e3);
|
|
604
|
+
}
|
|
605
|
+
function clamp(value, min, max) {
|
|
606
|
+
return Math.max(min, Math.min(max, value));
|
|
607
|
+
}
|
|
608
|
+
function sleep(ms) {
|
|
609
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// src/stepup/stepup.ts
|
|
613
|
+
var DEFAULT_POLLING_INTERVAL_MS = 250;
|
|
614
|
+
var DEFAULT_MAX_WAIT_MS = 15e3;
|
|
615
|
+
var DEFAULT_TTL_MIN_SECONDS = 300;
|
|
616
|
+
var DEFAULT_TTL_MAX_SECONDS = 900;
|
|
617
|
+
var DEFAULT_TTL_DEFAULT_SECONDS = 600;
|
|
618
|
+
var StepUpPoller = class {
|
|
619
|
+
httpClient;
|
|
620
|
+
tenantId;
|
|
621
|
+
pollingIntervalMs;
|
|
622
|
+
maxWaitMs;
|
|
623
|
+
ttlMinSeconds;
|
|
624
|
+
ttlMaxSeconds;
|
|
625
|
+
ttlDefaultSeconds;
|
|
626
|
+
constructor(config) {
|
|
627
|
+
this.httpClient = config.httpClient;
|
|
628
|
+
this.tenantId = config.tenantId;
|
|
629
|
+
this.pollingIntervalMs = config.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS;
|
|
630
|
+
this.maxWaitMs = config.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
|
|
631
|
+
this.ttlMinSeconds = config.ttlMinSeconds ?? DEFAULT_TTL_MIN_SECONDS;
|
|
632
|
+
this.ttlMaxSeconds = config.ttlMaxSeconds ?? DEFAULT_TTL_MAX_SECONDS;
|
|
633
|
+
this.ttlDefaultSeconds = config.ttlDefaultSeconds ?? DEFAULT_TTL_DEFAULT_SECONDS;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Get current step-up status
|
|
637
|
+
*/
|
|
638
|
+
async getStatus(requestId) {
|
|
639
|
+
const path = `/defense/stepup/status?tenantId=${encodeURIComponent(this.tenantId)}&requestId=${encodeURIComponent(requestId)}`;
|
|
640
|
+
try {
|
|
641
|
+
const apiResponse = await this.httpClient.request({
|
|
642
|
+
method: "GET",
|
|
643
|
+
path,
|
|
644
|
+
requestId
|
|
645
|
+
});
|
|
646
|
+
const response = {
|
|
647
|
+
status: apiResponse.status,
|
|
648
|
+
tenantId: apiResponse.tenant_id ?? apiResponse.tenantId,
|
|
649
|
+
requestId: apiResponse.request_id ?? apiResponse.requestId,
|
|
650
|
+
decision: apiResponse.decision,
|
|
651
|
+
reasonCodes: apiResponse.reason_codes ?? apiResponse.reasonCodes,
|
|
652
|
+
correlationId: apiResponse.correlation_id ?? apiResponse.correlationId,
|
|
653
|
+
expiresAtMs: apiResponse.expires_at_ms ?? apiResponse.expiresAtMs,
|
|
654
|
+
ttl: apiResponse.ttl
|
|
655
|
+
};
|
|
656
|
+
const now = nowEpochSeconds();
|
|
657
|
+
if (response.ttl !== void 0 && response.ttl <= now) {
|
|
658
|
+
return {
|
|
659
|
+
...response,
|
|
660
|
+
status: "EXPIRED"
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
return response;
|
|
664
|
+
} catch (error) {
|
|
665
|
+
if (error instanceof GateError && error.code === "NOT_FOUND" /* NOT_FOUND */) {
|
|
666
|
+
throw new GateError(
|
|
667
|
+
"NOT_FOUND" /* NOT_FOUND */,
|
|
668
|
+
`Step-up request not found: ${requestId}`,
|
|
669
|
+
{ requestId }
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Wait for step-up decision with polling
|
|
677
|
+
*
|
|
678
|
+
* Polls until status is APPROVED, DENIED, or EXPIRED, or timeout is reached.
|
|
679
|
+
*/
|
|
680
|
+
async awaitDecision(requestId, options) {
|
|
681
|
+
const startTime = Date.now();
|
|
682
|
+
const maxWaitMs = options?.maxWaitMs ?? this.maxWaitMs;
|
|
683
|
+
const intervalMs = options?.intervalMs ?? this.pollingIntervalMs;
|
|
684
|
+
while (true) {
|
|
685
|
+
const elapsedMs = Date.now() - startTime;
|
|
686
|
+
if (elapsedMs >= maxWaitMs) {
|
|
687
|
+
throw new GateError(
|
|
688
|
+
"STEP_UP_TIMEOUT" /* STEP_UP_TIMEOUT */,
|
|
689
|
+
`Step-up decision timeout after ${maxWaitMs}ms`,
|
|
690
|
+
{ requestId }
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
const status = await this.getStatus(requestId);
|
|
695
|
+
const now = nowEpochSeconds();
|
|
696
|
+
if (status.ttl !== void 0 && status.ttl <= now) {
|
|
697
|
+
return {
|
|
698
|
+
status: "EXPIRED",
|
|
699
|
+
requestId,
|
|
700
|
+
elapsedMs,
|
|
701
|
+
correlationId: status.correlationId
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
if (status.status === "APPROVED" || status.status === "DENIED" || status.status === "EXPIRED") {
|
|
705
|
+
return {
|
|
706
|
+
status: status.status,
|
|
707
|
+
requestId,
|
|
708
|
+
elapsedMs,
|
|
709
|
+
decision: status.decision,
|
|
710
|
+
reasonCodes: status.reasonCodes,
|
|
711
|
+
correlationId: status.correlationId
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
await sleep(intervalMs);
|
|
715
|
+
} catch (error) {
|
|
716
|
+
if (error instanceof GateError && error.code === "NOT_FOUND" /* NOT_FOUND */) {
|
|
717
|
+
throw error;
|
|
718
|
+
}
|
|
719
|
+
const remainingMs = maxWaitMs - (Date.now() - startTime);
|
|
720
|
+
if (remainingMs <= 0) {
|
|
721
|
+
throw new GateError(
|
|
722
|
+
"STEP_UP_TIMEOUT" /* STEP_UP_TIMEOUT */,
|
|
723
|
+
`Step-up decision timeout after ${maxWaitMs}ms`,
|
|
724
|
+
{ requestId, cause: error instanceof Error ? error : void 0 }
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
await sleep(Math.min(intervalMs, remainingMs));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Clamp TTL to guardrails
|
|
733
|
+
*/
|
|
734
|
+
clampTtl(ttlSeconds) {
|
|
735
|
+
if (ttlSeconds === void 0) {
|
|
736
|
+
return this.ttlDefaultSeconds;
|
|
737
|
+
}
|
|
738
|
+
return clamp(ttlSeconds, this.ttlMinSeconds, this.ttlMaxSeconds);
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
// src/circuit/CircuitBreaker.ts
|
|
743
|
+
var CircuitBreaker = class {
|
|
744
|
+
state = "CLOSED";
|
|
745
|
+
failures = 0;
|
|
746
|
+
successes = 0;
|
|
747
|
+
lastFailureTime;
|
|
748
|
+
lastSuccessTime;
|
|
749
|
+
tripsToOpen = 0;
|
|
750
|
+
tripThreshold;
|
|
751
|
+
coolDownMs;
|
|
752
|
+
constructor(config = {}) {
|
|
753
|
+
this.tripThreshold = config.tripAfterConsecutiveFailures ?? 5;
|
|
754
|
+
this.coolDownMs = config.coolDownMs ?? 3e4;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Execute function with circuit breaker protection
|
|
758
|
+
*/
|
|
759
|
+
async execute(fn) {
|
|
760
|
+
if (this.state === "OPEN") {
|
|
761
|
+
const now = Date.now();
|
|
762
|
+
const timeSinceLastFailure = this.lastFailureTime ? now - this.lastFailureTime : Infinity;
|
|
763
|
+
if (timeSinceLastFailure >= this.coolDownMs) {
|
|
764
|
+
this.state = "HALF_OPEN";
|
|
765
|
+
this.failures = 0;
|
|
766
|
+
} else {
|
|
767
|
+
throw new CircuitBreakerOpenError(
|
|
768
|
+
`Circuit breaker is OPEN. Will retry after ${this.coolDownMs - timeSinceLastFailure}ms`
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
try {
|
|
773
|
+
const result = await fn();
|
|
774
|
+
this.onSuccess();
|
|
775
|
+
return result;
|
|
776
|
+
} catch (error) {
|
|
777
|
+
this.onFailure();
|
|
778
|
+
throw error;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
onSuccess() {
|
|
782
|
+
this.successes++;
|
|
783
|
+
this.lastSuccessTime = Date.now();
|
|
784
|
+
if (this.state === "HALF_OPEN") {
|
|
785
|
+
this.state = "CLOSED";
|
|
786
|
+
this.failures = 0;
|
|
787
|
+
} else if (this.state === "CLOSED") {
|
|
788
|
+
this.failures = 0;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
onFailure() {
|
|
792
|
+
this.failures++;
|
|
793
|
+
this.lastFailureTime = Date.now();
|
|
794
|
+
if (this.state === "HALF_OPEN") {
|
|
795
|
+
this.state = "OPEN";
|
|
796
|
+
this.tripsToOpen++;
|
|
797
|
+
} else if (this.state === "CLOSED" && this.failures >= this.tripThreshold) {
|
|
798
|
+
this.state = "OPEN";
|
|
799
|
+
this.tripsToOpen++;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Get current metrics
|
|
804
|
+
*/
|
|
805
|
+
getMetrics() {
|
|
806
|
+
return {
|
|
807
|
+
failures: this.failures,
|
|
808
|
+
successes: this.successes,
|
|
809
|
+
state: this.state,
|
|
810
|
+
lastFailureTime: this.lastFailureTime,
|
|
811
|
+
lastSuccessTime: this.lastSuccessTime,
|
|
812
|
+
tripsToOpen: this.tripsToOpen
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Reset circuit breaker to CLOSED state
|
|
817
|
+
*/
|
|
818
|
+
reset() {
|
|
819
|
+
this.state = "CLOSED";
|
|
820
|
+
this.failures = 0;
|
|
821
|
+
this.successes = 0;
|
|
822
|
+
this.lastFailureTime = void 0;
|
|
823
|
+
this.lastSuccessTime = void 0;
|
|
824
|
+
this.tripsToOpen = 0;
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
var CircuitBreakerOpenError = class extends Error {
|
|
828
|
+
constructor(message) {
|
|
829
|
+
super(message);
|
|
830
|
+
this.name = "CircuitBreakerOpenError";
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
// src/metrics/MetricsCollector.ts
|
|
835
|
+
var MetricsCollector = class {
|
|
836
|
+
requestsTotal = 0;
|
|
837
|
+
allowedTotal = 0;
|
|
838
|
+
blockedTotal = 0;
|
|
839
|
+
stepupTotal = 0;
|
|
840
|
+
timeoutsTotal = 0;
|
|
841
|
+
errorsTotal = 0;
|
|
842
|
+
circuitBreakerOpenTotal = 0;
|
|
843
|
+
wouldBlockTotal = 0;
|
|
844
|
+
// Shadow mode would-block count
|
|
845
|
+
failOpenTotal = 0;
|
|
846
|
+
// Fail-open count
|
|
847
|
+
latencyMs = [];
|
|
848
|
+
maxSamples = 1e3;
|
|
849
|
+
// Keep last 1000 samples
|
|
850
|
+
hooks = [];
|
|
851
|
+
/**
|
|
852
|
+
* Record a request
|
|
853
|
+
*/
|
|
854
|
+
recordRequest(decision, latencyMs) {
|
|
855
|
+
this.requestsTotal++;
|
|
856
|
+
if (decision === "ALLOW") {
|
|
857
|
+
this.allowedTotal++;
|
|
858
|
+
} else if (decision === "BLOCK") {
|
|
859
|
+
this.blockedTotal++;
|
|
860
|
+
} else if (decision === "REQUIRE_STEP_UP") {
|
|
861
|
+
this.stepupTotal++;
|
|
862
|
+
} else if (decision === "WOULD_BLOCK") {
|
|
863
|
+
this.wouldBlockTotal++;
|
|
864
|
+
this.allowedTotal++;
|
|
865
|
+
} else if (decision === "FAIL_OPEN") {
|
|
866
|
+
this.failOpenTotal++;
|
|
867
|
+
this.allowedTotal++;
|
|
868
|
+
}
|
|
869
|
+
this.latencyMs.push(latencyMs);
|
|
870
|
+
if (this.latencyMs.length > this.maxSamples) {
|
|
871
|
+
this.latencyMs.shift();
|
|
872
|
+
}
|
|
873
|
+
this.emitMetrics();
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Record a timeout
|
|
877
|
+
*/
|
|
878
|
+
recordTimeout() {
|
|
879
|
+
this.timeoutsTotal++;
|
|
880
|
+
this.errorsTotal++;
|
|
881
|
+
this.emitMetrics();
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Record an error
|
|
885
|
+
*/
|
|
886
|
+
recordError() {
|
|
887
|
+
this.errorsTotal++;
|
|
888
|
+
this.emitMetrics();
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Record circuit breaker open
|
|
892
|
+
*/
|
|
893
|
+
recordCircuitBreakerOpen() {
|
|
894
|
+
this.circuitBreakerOpenTotal++;
|
|
895
|
+
this.emitMetrics();
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Record soft-enforce override (app chose to sign despite BLOCK decision)
|
|
899
|
+
*/
|
|
900
|
+
recordSoftBlockOverride(decision) {
|
|
901
|
+
this.emitMetrics();
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Get current metrics snapshot
|
|
905
|
+
*/
|
|
906
|
+
getMetrics() {
|
|
907
|
+
return {
|
|
908
|
+
requestsTotal: this.requestsTotal,
|
|
909
|
+
allowedTotal: this.allowedTotal,
|
|
910
|
+
blockedTotal: this.blockedTotal,
|
|
911
|
+
stepupTotal: this.stepupTotal,
|
|
912
|
+
timeoutsTotal: this.timeoutsTotal,
|
|
913
|
+
errorsTotal: this.errorsTotal,
|
|
914
|
+
circuitBreakerOpenTotal: this.circuitBreakerOpenTotal,
|
|
915
|
+
wouldBlockTotal: this.wouldBlockTotal,
|
|
916
|
+
failOpenTotal: this.failOpenTotal,
|
|
917
|
+
latencyMs: [...this.latencyMs]
|
|
918
|
+
// Copy array
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Register a metrics hook (e.g., for Prometheus/OpenTelemetry export)
|
|
923
|
+
*/
|
|
924
|
+
registerHook(hook) {
|
|
925
|
+
this.hooks.push(hook);
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Emit metrics to all registered hooks
|
|
929
|
+
*/
|
|
930
|
+
emitMetrics() {
|
|
931
|
+
const metrics = this.getMetrics();
|
|
932
|
+
for (const hook of this.hooks) {
|
|
933
|
+
try {
|
|
934
|
+
hook(metrics);
|
|
935
|
+
} catch (error) {
|
|
936
|
+
console.error("Error in metrics hook:", error);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Reset all metrics
|
|
942
|
+
*/
|
|
943
|
+
reset() {
|
|
944
|
+
this.requestsTotal = 0;
|
|
945
|
+
this.allowedTotal = 0;
|
|
946
|
+
this.blockedTotal = 0;
|
|
947
|
+
this.stepupTotal = 0;
|
|
948
|
+
this.timeoutsTotal = 0;
|
|
949
|
+
this.errorsTotal = 0;
|
|
950
|
+
this.circuitBreakerOpenTotal = 0;
|
|
951
|
+
this.wouldBlockTotal = 0;
|
|
952
|
+
this.failOpenTotal = 0;
|
|
953
|
+
this.latencyMs = [];
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
function canonicalJsonBinding(obj) {
|
|
957
|
+
if (obj === null || obj === void 0) return "null";
|
|
958
|
+
if (typeof obj === "string") return JSON.stringify(obj);
|
|
959
|
+
if (typeof obj === "number") return obj.toString();
|
|
960
|
+
if (typeof obj === "boolean") return obj ? "true" : "false";
|
|
961
|
+
if (Array.isArray(obj)) {
|
|
962
|
+
const items = obj.map((item) => canonicalJsonBinding(item));
|
|
963
|
+
return "[" + items.join(",") + "]";
|
|
964
|
+
}
|
|
965
|
+
if (typeof obj === "object") {
|
|
966
|
+
const keys = Object.keys(obj).sort();
|
|
967
|
+
const pairs = [];
|
|
968
|
+
for (const key of keys) {
|
|
969
|
+
const value = obj[key];
|
|
970
|
+
if (value !== void 0) {
|
|
971
|
+
pairs.push(JSON.stringify(key) + ":" + canonicalJsonBinding(value));
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
return "{" + pairs.join(",") + "}";
|
|
975
|
+
}
|
|
976
|
+
return JSON.stringify(obj);
|
|
977
|
+
}
|
|
978
|
+
function normalizeAddress(addr) {
|
|
979
|
+
if (addr == null || addr === "") return "";
|
|
980
|
+
const s = String(addr).trim();
|
|
981
|
+
if (s.startsWith("0x")) return s.toLowerCase();
|
|
982
|
+
return "0x" + s.toLowerCase();
|
|
983
|
+
}
|
|
984
|
+
function normalizeData(data) {
|
|
985
|
+
if (data == null || data === "") return "";
|
|
986
|
+
const s = String(data).trim().toLowerCase();
|
|
987
|
+
return s.startsWith("0x") ? s : "0x" + s;
|
|
988
|
+
}
|
|
989
|
+
function buildTxBindingObject(txIntent, signerId, decodedRecipient, decodedFields, fromAddress) {
|
|
990
|
+
const toAddr = txIntent.toAddress ?? txIntent.to ?? "";
|
|
991
|
+
const value = (txIntent.valueAtomic ?? txIntent.valueDecimal ?? txIntent.value ?? "0").toString();
|
|
992
|
+
const data = normalizeData(
|
|
993
|
+
txIntent.data ?? txIntent.payloadHash ?? txIntent.dataHash ?? ""
|
|
994
|
+
);
|
|
995
|
+
const chainId = (txIntent.chainId ?? txIntent.chain ?? "").toString();
|
|
996
|
+
const toAddress = normalizeAddress(toAddr);
|
|
997
|
+
const nonce = txIntent.nonce != null ? String(txIntent.nonce) : "";
|
|
998
|
+
const decoded = {};
|
|
999
|
+
const out = {
|
|
1000
|
+
chainId,
|
|
1001
|
+
toAddress,
|
|
1002
|
+
value,
|
|
1003
|
+
data,
|
|
1004
|
+
nonce
|
|
1005
|
+
};
|
|
1006
|
+
if (fromAddress) out.fromAddress = normalizeAddress(fromAddress);
|
|
1007
|
+
if (Object.keys(decoded).length > 0) out.decoded = decoded;
|
|
1008
|
+
if (signerId) out.signerId = signerId;
|
|
1009
|
+
if (txIntent.networkFamily) out.networkFamily = txIntent.networkFamily;
|
|
1010
|
+
return out;
|
|
1011
|
+
}
|
|
1012
|
+
function computeTxDigest(binding) {
|
|
1013
|
+
const canonical = canonicalJsonBinding(binding);
|
|
1014
|
+
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// src/kms/wrapAwsSdkV3KmsClient.ts
|
|
1018
|
+
function wrapKmsClient(kmsClient, gateClient, options = {}) {
|
|
1019
|
+
const defaultOptions = {
|
|
1020
|
+
mode: options.mode || "enforce",
|
|
1021
|
+
onDecision: options.onDecision || (() => {
|
|
1022
|
+
}),
|
|
1023
|
+
extractTxIntent: options.extractTxIntent || defaultExtractTxIntent
|
|
1024
|
+
};
|
|
1025
|
+
const wrapped = new Proxy(kmsClient, {
|
|
1026
|
+
get(target, prop, receiver) {
|
|
1027
|
+
if (prop === "send") {
|
|
1028
|
+
return async function(command) {
|
|
1029
|
+
if (command && command.constructor && command.constructor.name === "SignCommand") {
|
|
1030
|
+
return await handleSignCommand(
|
|
1031
|
+
command,
|
|
1032
|
+
target,
|
|
1033
|
+
gateClient,
|
|
1034
|
+
defaultOptions
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
return await target.send(command);
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
return Reflect.get(target, prop, receiver);
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
wrapped._originalClient = kmsClient;
|
|
1044
|
+
wrapped._gateClient = gateClient;
|
|
1045
|
+
wrapped._wrapperOptions = defaultOptions;
|
|
1046
|
+
return wrapped;
|
|
1047
|
+
}
|
|
1048
|
+
function defaultExtractTxIntent(command) {
|
|
1049
|
+
const message = command.input?.Message ?? command.Message;
|
|
1050
|
+
if (!message) {
|
|
1051
|
+
throw new Error("SignCommand missing required Message property");
|
|
1052
|
+
}
|
|
1053
|
+
const messageBuffer = message instanceof Buffer ? message : Buffer.from(message);
|
|
1054
|
+
const messageHash = createHash("sha256").update(messageBuffer).digest("hex");
|
|
1055
|
+
return {
|
|
1056
|
+
networkFamily: "OTHER",
|
|
1057
|
+
toAddress: void 0,
|
|
1058
|
+
// Unknown from KMS message alone
|
|
1059
|
+
payloadHash: messageHash,
|
|
1060
|
+
dataHash: messageHash
|
|
1061
|
+
// Backward compatibility
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
async function handleSignCommand(command, originalClient, gateClient, options) {
|
|
1065
|
+
const txIntent = options.extractTxIntent(command);
|
|
1066
|
+
const signerId = command.input?.KeyId ?? command.KeyId ?? "unknown";
|
|
1067
|
+
gateClient.heartbeatManager.updateSignerId(signerId);
|
|
1068
|
+
const heartbeatToken = gateClient.heartbeatManager.getToken();
|
|
1069
|
+
if (!heartbeatToken) {
|
|
1070
|
+
throw new BlockIntelBlockedError(
|
|
1071
|
+
"HEARTBEAT_MISSING",
|
|
1072
|
+
void 0,
|
|
1073
|
+
// receiptId
|
|
1074
|
+
void 0,
|
|
1075
|
+
// correlationId
|
|
1076
|
+
void 0
|
|
1077
|
+
// requestId
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
const signingContext = {
|
|
1081
|
+
signerId,
|
|
1082
|
+
actorPrincipal: "kms-signer",
|
|
1083
|
+
// Default - can be customized via extractTxIntent
|
|
1084
|
+
heartbeatToken
|
|
1085
|
+
// Attach heartbeat token
|
|
1086
|
+
};
|
|
1087
|
+
try {
|
|
1088
|
+
const decision = await gateClient.evaluate({
|
|
1089
|
+
txIntent,
|
|
1090
|
+
// Type assertion - txIntent may have extra fields
|
|
1091
|
+
signingContext
|
|
1092
|
+
});
|
|
1093
|
+
if (decision.decision === "ALLOW" && gateClient.getRequireDecisionToken() && decision.txDigest != null) {
|
|
1094
|
+
const binding = buildTxBindingObject(
|
|
1095
|
+
txIntent,
|
|
1096
|
+
signerId,
|
|
1097
|
+
void 0,
|
|
1098
|
+
void 0,
|
|
1099
|
+
signingContext.actorPrincipal
|
|
1100
|
+
);
|
|
1101
|
+
const computedDigest = computeTxDigest(binding);
|
|
1102
|
+
if (computedDigest !== decision.txDigest) {
|
|
1103
|
+
options.onDecision("BLOCK", {
|
|
1104
|
+
error: new BlockIntelBlockedError(
|
|
1105
|
+
"DECISION_TOKEN_TX_MISMATCH",
|
|
1106
|
+
decision.decisionId,
|
|
1107
|
+
decision.correlationId,
|
|
1108
|
+
void 0
|
|
1109
|
+
),
|
|
1110
|
+
signerId,
|
|
1111
|
+
command
|
|
1112
|
+
});
|
|
1113
|
+
throw new BlockIntelBlockedError(
|
|
1114
|
+
"DECISION_TOKEN_TX_MISMATCH",
|
|
1115
|
+
decision.decisionId,
|
|
1116
|
+
decision.correlationId,
|
|
1117
|
+
void 0
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
options.onDecision("ALLOW", { decision, signerId, command });
|
|
1122
|
+
if (options.mode === "dry-run") {
|
|
1123
|
+
return await originalClient.send(new SignCommand(command));
|
|
1124
|
+
}
|
|
1125
|
+
return await originalClient.send(new SignCommand(command));
|
|
1126
|
+
} catch (error) {
|
|
1127
|
+
if (error instanceof BlockIntelBlockedError) {
|
|
1128
|
+
options.onDecision("BLOCK", { error, signerId, command });
|
|
1129
|
+
throw error;
|
|
1130
|
+
}
|
|
1131
|
+
if (error instanceof BlockIntelStepUpRequiredError) {
|
|
1132
|
+
options.onDecision("REQUIRE_STEP_UP", { error, signerId, command });
|
|
1133
|
+
throw error;
|
|
1134
|
+
}
|
|
1135
|
+
throw error;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// src/provenance/ProvenanceProvider.ts
|
|
1140
|
+
var ProvenanceProvider = class {
|
|
1141
|
+
/**
|
|
1142
|
+
* Get provenance from environment variables
|
|
1143
|
+
*/
|
|
1144
|
+
static getProvenance() {
|
|
1145
|
+
const repo = process.env.GATE_CALLER_REPO;
|
|
1146
|
+
const workflow = process.env.GATE_CALLER_WORKFLOW;
|
|
1147
|
+
const ref = process.env.GATE_CALLER_REF;
|
|
1148
|
+
const actor = process.env.GATE_CALLER_ACTOR;
|
|
1149
|
+
const attestationValid = process.env.GATE_ATTESTATION_VALID;
|
|
1150
|
+
const attestationIssuer = process.env.GATE_ATTESTATION_ISSUER;
|
|
1151
|
+
const attestationSubject = process.env.GATE_ATTESTATION_SUBJECT;
|
|
1152
|
+
const attestationSha = process.env.GATE_ATTESTATION_SHA;
|
|
1153
|
+
if (!repo && !workflow && !ref && !actor && !attestationValid) {
|
|
1154
|
+
return null;
|
|
1155
|
+
}
|
|
1156
|
+
const provenance = {};
|
|
1157
|
+
if (repo) provenance.repo = repo;
|
|
1158
|
+
if (workflow) provenance.workflow = workflow;
|
|
1159
|
+
if (ref) provenance.ref = ref;
|
|
1160
|
+
if (actor) provenance.actor = actor;
|
|
1161
|
+
if (attestationValid || attestationIssuer || attestationSubject || attestationSha) {
|
|
1162
|
+
provenance.attestation = {
|
|
1163
|
+
valid: attestationValid === "true" || attestationValid === "1",
|
|
1164
|
+
issuer: attestationIssuer,
|
|
1165
|
+
subject: attestationSubject,
|
|
1166
|
+
sha: attestationSha
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
return provenance;
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Check if provenance is enabled (env vars present)
|
|
1173
|
+
*/
|
|
1174
|
+
static isEnabled() {
|
|
1175
|
+
return !!(process.env.GATE_CALLER_REPO || process.env.GATE_CALLER_WORKFLOW || process.env.GATE_ATTESTATION_VALID);
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
var HeartbeatManager = class {
|
|
1179
|
+
httpClient;
|
|
1180
|
+
tenantId;
|
|
1181
|
+
signerId;
|
|
1182
|
+
environment;
|
|
1183
|
+
baseRefreshIntervalSeconds;
|
|
1184
|
+
clientInstanceId;
|
|
1185
|
+
// Unique per process
|
|
1186
|
+
sdkVersion;
|
|
1187
|
+
// SDK version for tracking
|
|
1188
|
+
apiKey;
|
|
1189
|
+
// x-gate-heartbeat-key for Control Plane auth
|
|
1190
|
+
currentToken = null;
|
|
1191
|
+
refreshTimer = null;
|
|
1192
|
+
started = false;
|
|
1193
|
+
consecutiveFailures = 0;
|
|
1194
|
+
maxBackoffSeconds = 30;
|
|
1195
|
+
// Maximum backoff interval
|
|
1196
|
+
constructor(options) {
|
|
1197
|
+
this.httpClient = options.httpClient;
|
|
1198
|
+
this.tenantId = options.tenantId;
|
|
1199
|
+
this.signerId = options.signerId;
|
|
1200
|
+
this.environment = options.environment ?? "prod";
|
|
1201
|
+
this.baseRefreshIntervalSeconds = options.refreshIntervalSeconds ?? 10;
|
|
1202
|
+
this.apiKey = options.apiKey;
|
|
1203
|
+
this.clientInstanceId = options.clientInstanceId || v4();
|
|
1204
|
+
this.sdkVersion = options.sdkVersion || "1.0.0";
|
|
1205
|
+
this.apiKey = options.apiKey;
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Start background heartbeat refresher.
|
|
1209
|
+
* Optionally wait for initial token (first evaluate() will otherwise wait up to 2s for token).
|
|
1210
|
+
*/
|
|
1211
|
+
start(options) {
|
|
1212
|
+
if (this.started) {
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
this.started = true;
|
|
1216
|
+
this.acquireHeartbeat().catch((error) => {
|
|
1217
|
+
console.error("[HEARTBEAT] Failed to acquire initial heartbeat:", error instanceof Error ? error.message : error);
|
|
1218
|
+
});
|
|
1219
|
+
this.scheduleNextRefresh();
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Schedule next refresh with jitter and backoff
|
|
1223
|
+
*/
|
|
1224
|
+
scheduleNextRefresh() {
|
|
1225
|
+
if (!this.started) {
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
const baseInterval = this.baseRefreshIntervalSeconds * 1e3;
|
|
1229
|
+
const jitter = Math.random() * 2e3;
|
|
1230
|
+
const backoff = this.calculateBackoff();
|
|
1231
|
+
const interval = baseInterval + jitter + backoff;
|
|
1232
|
+
this.refreshTimer = setTimeout(() => {
|
|
1233
|
+
this.acquireHeartbeat().then(() => {
|
|
1234
|
+
this.consecutiveFailures = 0;
|
|
1235
|
+
this.scheduleNextRefresh();
|
|
1236
|
+
}).catch((error) => {
|
|
1237
|
+
this.consecutiveFailures++;
|
|
1238
|
+
console.error("[HEARTBEAT] Refresh failed (will retry):", error);
|
|
1239
|
+
this.scheduleNextRefresh();
|
|
1240
|
+
});
|
|
1241
|
+
}, interval);
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Calculate exponential backoff (capped at maxBackoffSeconds)
|
|
1245
|
+
*/
|
|
1246
|
+
calculateBackoff() {
|
|
1247
|
+
if (this.consecutiveFailures === 0) {
|
|
1248
|
+
return 0;
|
|
1249
|
+
}
|
|
1250
|
+
const backoffSeconds = Math.min(
|
|
1251
|
+
Math.pow(2, this.consecutiveFailures) * 1e3,
|
|
1252
|
+
this.maxBackoffSeconds * 1e3
|
|
1253
|
+
);
|
|
1254
|
+
return backoffSeconds;
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Stop background heartbeat refresher
|
|
1258
|
+
*/
|
|
1259
|
+
stop() {
|
|
1260
|
+
if (!this.started) {
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
this.started = false;
|
|
1264
|
+
if (this.refreshTimer) {
|
|
1265
|
+
clearTimeout(this.refreshTimer);
|
|
1266
|
+
this.refreshTimer = null;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Get current heartbeat token if valid
|
|
1271
|
+
*/
|
|
1272
|
+
getToken() {
|
|
1273
|
+
if (!this.currentToken) {
|
|
1274
|
+
return null;
|
|
1275
|
+
}
|
|
1276
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1277
|
+
if (this.currentToken.expiresAt <= now + 2) {
|
|
1278
|
+
return null;
|
|
1279
|
+
}
|
|
1280
|
+
return this.currentToken.token;
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Check if current heartbeat token is valid
|
|
1284
|
+
*/
|
|
1285
|
+
isValid() {
|
|
1286
|
+
return this.getToken() !== null;
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Update signer ID (called when signer is known)
|
|
1290
|
+
*/
|
|
1291
|
+
updateSignerId(signerId) {
|
|
1292
|
+
if (this.signerId !== signerId) {
|
|
1293
|
+
this.signerId = signerId;
|
|
1294
|
+
this.currentToken = null;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Acquire a new heartbeat token from Control Plane
|
|
1299
|
+
* NEVER logs token value (security)
|
|
1300
|
+
* Requires x-gate-heartbeat-key header (apiKey) for authentication.
|
|
1301
|
+
*/
|
|
1302
|
+
async acquireHeartbeat() {
|
|
1303
|
+
if (!this.apiKey || this.apiKey.length === 0) {
|
|
1304
|
+
throw new GateError(
|
|
1305
|
+
"UNAUTHORIZED" /* UNAUTHORIZED */,
|
|
1306
|
+
"Heartbeat API key is required. Set GATE_HEARTBEAT_KEY in environment or pass heartbeatApiKey in GateClientConfig.",
|
|
1307
|
+
{}
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
try {
|
|
1311
|
+
const response = await this.httpClient.request({
|
|
1312
|
+
method: "POST",
|
|
1313
|
+
path: "/api/v1/gate/heartbeat",
|
|
1314
|
+
headers: {
|
|
1315
|
+
"x-gate-heartbeat-key": this.apiKey
|
|
1316
|
+
},
|
|
1317
|
+
body: {
|
|
1318
|
+
tenantId: this.tenantId,
|
|
1319
|
+
signerId: this.signerId,
|
|
1320
|
+
environment: this.environment,
|
|
1321
|
+
clientInstanceId: this.clientInstanceId,
|
|
1322
|
+
sdkVersion: this.sdkVersion
|
|
1323
|
+
}
|
|
1324
|
+
});
|
|
1325
|
+
if (response.success && response.data) {
|
|
1326
|
+
const token = response.data.heartbeatToken;
|
|
1327
|
+
const expiresAt = response.data.expiresAt;
|
|
1328
|
+
if (!token || !expiresAt) {
|
|
1329
|
+
throw new GateError(
|
|
1330
|
+
"INVALID_RESPONSE" /* INVALID_RESPONSE */,
|
|
1331
|
+
"Invalid heartbeat response: missing token or expiresAt"
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
this.currentToken = {
|
|
1335
|
+
token,
|
|
1336
|
+
expiresAt,
|
|
1337
|
+
jti: response.data.jti,
|
|
1338
|
+
policyHash: response.data.policyHash
|
|
1339
|
+
};
|
|
1340
|
+
console.log("[HEARTBEAT] Acquired heartbeat token", {
|
|
1341
|
+
expiresAt,
|
|
1342
|
+
jti: response.data.jti,
|
|
1343
|
+
policyHash: response.data.policyHash?.substring(0, 8) + "..."
|
|
1344
|
+
// DO NOT log token value
|
|
1345
|
+
});
|
|
1346
|
+
} else {
|
|
1347
|
+
const error = response.error || {};
|
|
1348
|
+
throw new GateError(
|
|
1349
|
+
"SERVER_ERROR" /* SERVER_ERROR */,
|
|
1350
|
+
`Heartbeat acquisition failed: ${error.message || "Unknown error"}`
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
} catch (error) {
|
|
1354
|
+
console.error("[HEARTBEAT] Failed to acquire heartbeat:", error.message || error);
|
|
1355
|
+
throw error;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* Get client instance ID (for tracking)
|
|
1360
|
+
*/
|
|
1361
|
+
getClientInstanceId() {
|
|
1362
|
+
return this.clientInstanceId;
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
|
|
1366
|
+
// src/security/IamPermissionRiskChecker.ts
|
|
1367
|
+
var IamPermissionRiskChecker = class {
|
|
1368
|
+
options;
|
|
1369
|
+
constructor(options) {
|
|
1370
|
+
this.options = options;
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Perform synchronous IAM permission risk check
|
|
1374
|
+
*
|
|
1375
|
+
* Performs quick checks (credentials, environment markers) synchronously.
|
|
1376
|
+
* In HARD mode, throws error if risk detected and override not set.
|
|
1377
|
+
*
|
|
1378
|
+
* Use this for blocking initialization checks.
|
|
1379
|
+
*/
|
|
1380
|
+
checkSync() {
|
|
1381
|
+
const checks = [];
|
|
1382
|
+
const credentialsCheck = this.checkAwsCredentials();
|
|
1383
|
+
if (credentialsCheck.hasRisk) {
|
|
1384
|
+
checks.push(credentialsCheck);
|
|
1385
|
+
}
|
|
1386
|
+
const envCheck = this.checkEnvironmentMarkers();
|
|
1387
|
+
if (envCheck.hasRisk) {
|
|
1388
|
+
checks.push(envCheck);
|
|
1389
|
+
}
|
|
1390
|
+
const highestConfidence = this.getHighestConfidence(checks);
|
|
1391
|
+
const highestRisk = checks.find((c) => c.confidence === highestConfidence);
|
|
1392
|
+
if (!highestRisk || !highestRisk.hasRisk) {
|
|
1393
|
+
return {
|
|
1394
|
+
hasRisk: false,
|
|
1395
|
+
confidence: "LOW",
|
|
1396
|
+
details: "No IAM permission risk detected (synchronous check)"
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
if (this.options.enforcementMode === "HARD" && !this.options.allowInsecureKmsSignPermission) {
|
|
1400
|
+
const errorMessage = this.buildErrorMessage(highestRisk);
|
|
1401
|
+
throw new Error(errorMessage);
|
|
1402
|
+
}
|
|
1403
|
+
this.logWarning(highestRisk);
|
|
1404
|
+
return highestRisk;
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Perform full IAM permission risk check (including async IAM simulation)
|
|
1408
|
+
*
|
|
1409
|
+
* Returns risk assessment with confidence level.
|
|
1410
|
+
* In HARD mode, throws error if risk detected and override not set.
|
|
1411
|
+
*/
|
|
1412
|
+
async check() {
|
|
1413
|
+
const syncResult = this.checkSync();
|
|
1414
|
+
const simulationCheck = await this.checkIamSimulation();
|
|
1415
|
+
if (simulationCheck.hasRisk) {
|
|
1416
|
+
if (this.options.enforcementMode === "HARD" && !this.options.allowInsecureKmsSignPermission) {
|
|
1417
|
+
const errorMessage = this.buildErrorMessage(simulationCheck);
|
|
1418
|
+
throw new Error(errorMessage);
|
|
1419
|
+
}
|
|
1420
|
+
this.logWarning(simulationCheck);
|
|
1421
|
+
return simulationCheck;
|
|
1422
|
+
}
|
|
1423
|
+
return syncResult;
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Check if AWS credentials are present
|
|
1427
|
+
*/
|
|
1428
|
+
checkAwsCredentials() {
|
|
1429
|
+
const hasEnvVars = !!(process.env.AWS_ACCESS_KEY_ID || process.env.AWS_SECRET_ACCESS_KEY || process.env.AWS_SESSION_TOKEN);
|
|
1430
|
+
const hasRoleCredentials = !!(process.env.AWS_ROLE_ARN || process.env.AWS_WEB_IDENTITY_TOKEN_FILE || process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI);
|
|
1431
|
+
if (hasEnvVars || hasRoleCredentials) {
|
|
1432
|
+
return {
|
|
1433
|
+
hasRisk: true,
|
|
1434
|
+
riskType: "AWS_CREDENTIALS_DETECTED",
|
|
1435
|
+
confidence: "MEDIUM",
|
|
1436
|
+
details: "AWS credentials detected in environment. Application may have direct KMS signing permissions.",
|
|
1437
|
+
remediation: "Remove kms:Sign permission from application role. See https://docs.blockintelai.com/gate/IAM_HARDENING"
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
return {
|
|
1441
|
+
hasRisk: false,
|
|
1442
|
+
confidence: "LOW",
|
|
1443
|
+
details: "No AWS credentials detected in environment variables"
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Check IAM permissions using simulation API (if available)
|
|
1448
|
+
*/
|
|
1449
|
+
async checkIamSimulation() {
|
|
1450
|
+
try {
|
|
1451
|
+
const iamModule = await import('@aws-sdk/client-iam').catch(() => null);
|
|
1452
|
+
if (!iamModule || !iamModule.IAMClient || !iamModule.SimulatePrincipalPolicyCommand) {
|
|
1453
|
+
return {
|
|
1454
|
+
hasRisk: false,
|
|
1455
|
+
confidence: "LOW",
|
|
1456
|
+
details: "AWS SDK not available for IAM simulation"
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
const { IAMClient, SimulatePrincipalPolicyCommand } = iamModule;
|
|
1460
|
+
const principalArn = await this.getCurrentPrincipalArn();
|
|
1461
|
+
if (!principalArn) {
|
|
1462
|
+
return {
|
|
1463
|
+
hasRisk: false,
|
|
1464
|
+
confidence: "LOW",
|
|
1465
|
+
details: "Could not determine current principal ARN for simulation"
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
const client = new IAMClient({});
|
|
1469
|
+
const command = new SimulatePrincipalPolicyCommand({
|
|
1470
|
+
PolicySourceArn: principalArn,
|
|
1471
|
+
ActionNames: ["kms:Sign"],
|
|
1472
|
+
ResourceArns: this.options.kmsKeyIds?.map((id) => `arn:aws:kms:*:*:key/${id}`) || ["arn:aws:kms:*:*:key/*"]
|
|
1473
|
+
});
|
|
1474
|
+
const response = await client.send(command).catch(() => null);
|
|
1475
|
+
if (!response) {
|
|
1476
|
+
return {
|
|
1477
|
+
hasRisk: false,
|
|
1478
|
+
confidence: "LOW",
|
|
1479
|
+
details: "IAM simulation not available (may require additional permissions)"
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
const allowsSign = response.EvaluationResults?.some(
|
|
1483
|
+
(result) => result.EvalDecision === "allowed" || result.EvalDecision === "explicitAllow"
|
|
1484
|
+
);
|
|
1485
|
+
if (allowsSign) {
|
|
1486
|
+
return {
|
|
1487
|
+
hasRisk: true,
|
|
1488
|
+
riskType: "DIRECT_KMS_SIGN_PERMISSION",
|
|
1489
|
+
confidence: "HIGH",
|
|
1490
|
+
details: `IAM simulation confirms principal ${principalArn} has kms:Sign permission. Direct KMS signing can bypass Gate.`,
|
|
1491
|
+
remediation: "Remove kms:Sign permission from application role. See https://docs.blockintelai.com/gate/IAM_HARDENING"
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
return {
|
|
1495
|
+
hasRisk: false,
|
|
1496
|
+
confidence: "HIGH",
|
|
1497
|
+
details: "IAM simulation confirms no kms:Sign permission"
|
|
1498
|
+
};
|
|
1499
|
+
} catch (error) {
|
|
1500
|
+
return {
|
|
1501
|
+
hasRisk: false,
|
|
1502
|
+
confidence: "LOW",
|
|
1503
|
+
details: `IAM simulation failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Check environment markers that suggest direct KMS usage
|
|
1509
|
+
*/
|
|
1510
|
+
checkEnvironmentMarkers() {
|
|
1511
|
+
const markers = [
|
|
1512
|
+
"KMS_KEY_ID",
|
|
1513
|
+
"AWS_KMS_KEY_ID",
|
|
1514
|
+
"KMS_KEY_ARN",
|
|
1515
|
+
"AWS_KMS_KEY_ARN"
|
|
1516
|
+
];
|
|
1517
|
+
const foundMarkers = markers.filter((marker) => process.env[marker]);
|
|
1518
|
+
if (foundMarkers.length > 0) {
|
|
1519
|
+
return {
|
|
1520
|
+
hasRisk: true,
|
|
1521
|
+
riskType: "ENVIRONMENT_MARKERS",
|
|
1522
|
+
confidence: "LOW",
|
|
1523
|
+
details: `Environment markers suggest direct KMS usage: ${foundMarkers.join(", ")}`,
|
|
1524
|
+
remediation: "Review environment variables and ensure KMS access is gated through Gate SDK"
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
return {
|
|
1528
|
+
hasRisk: false,
|
|
1529
|
+
confidence: "LOW",
|
|
1530
|
+
details: "No environment markers suggesting direct KMS usage"
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* Get current principal ARN (best-effort)
|
|
1535
|
+
*/
|
|
1536
|
+
async getCurrentPrincipalArn() {
|
|
1537
|
+
try {
|
|
1538
|
+
const stsModule = await import('@aws-sdk/client-sts').catch(() => null);
|
|
1539
|
+
if (!stsModule || !stsModule.STSClient || !stsModule.GetCallerIdentityCommand) {
|
|
1540
|
+
return null;
|
|
1541
|
+
}
|
|
1542
|
+
const { STSClient, GetCallerIdentityCommand } = stsModule;
|
|
1543
|
+
const client = new STSClient({});
|
|
1544
|
+
const command = new GetCallerIdentityCommand({});
|
|
1545
|
+
const response = await client.send(command).catch(() => null);
|
|
1546
|
+
if (response?.Arn) {
|
|
1547
|
+
return response.Arn;
|
|
1548
|
+
}
|
|
1549
|
+
} catch (error) {
|
|
1550
|
+
}
|
|
1551
|
+
return null;
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Get highest confidence level from checks
|
|
1555
|
+
*/
|
|
1556
|
+
getHighestConfidence(checks) {
|
|
1557
|
+
if (checks.some((c) => c.confidence === "HIGH")) {
|
|
1558
|
+
return "HIGH";
|
|
1559
|
+
}
|
|
1560
|
+
if (checks.some((c) => c.confidence === "MEDIUM")) {
|
|
1561
|
+
return "MEDIUM";
|
|
1562
|
+
}
|
|
1563
|
+
return "LOW";
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Build error message for HARD mode
|
|
1567
|
+
*/
|
|
1568
|
+
buildErrorMessage(result) {
|
|
1569
|
+
const parts = [
|
|
1570
|
+
"[GATE ERROR] Hard enforcement mode blocked initialization:",
|
|
1571
|
+
` - IAM permission risk: ${result.details}`,
|
|
1572
|
+
` - Risk type: ${result.riskType}`,
|
|
1573
|
+
` - Confidence: ${result.confidence}`,
|
|
1574
|
+
` - Tenant ID: ${this.options.tenantId}`
|
|
1575
|
+
];
|
|
1576
|
+
if (this.options.signerId) {
|
|
1577
|
+
parts.push(` - Signer ID: ${this.options.signerId}`);
|
|
1578
|
+
}
|
|
1579
|
+
if (this.options.environment) {
|
|
1580
|
+
parts.push(` - Environment: ${this.options.environment}`);
|
|
1581
|
+
}
|
|
1582
|
+
if (result.remediation) {
|
|
1583
|
+
parts.push(` - Remediation: ${result.remediation}`);
|
|
1584
|
+
}
|
|
1585
|
+
parts.push(" - See: https://docs.blockintelai.com/gate/IAM_HARDENING");
|
|
1586
|
+
parts.push(` - Override: Set allowInsecureKmsSignPermission=true (not recommended for production)`);
|
|
1587
|
+
return parts.join("\n");
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* Log warning (SOFT mode or override set)
|
|
1591
|
+
*/
|
|
1592
|
+
logWarning(result) {
|
|
1593
|
+
const logData = {
|
|
1594
|
+
level: "WARN",
|
|
1595
|
+
message: "IAM permission risk detected",
|
|
1596
|
+
tenantId: this.options.tenantId,
|
|
1597
|
+
signerId: this.options.signerId,
|
|
1598
|
+
environment: this.options.environment,
|
|
1599
|
+
enforcementMode: this.options.enforcementMode,
|
|
1600
|
+
riskType: result.riskType,
|
|
1601
|
+
confidence: result.confidence,
|
|
1602
|
+
details: result.details,
|
|
1603
|
+
remediation: result.remediation,
|
|
1604
|
+
documentation: "https://docs.blockintelai.com/gate/IAM_HARDENING"
|
|
1605
|
+
};
|
|
1606
|
+
console.warn("[GATE WARNING]", JSON.stringify(logData, null, 2));
|
|
1607
|
+
}
|
|
1608
|
+
};
|
|
1609
|
+
|
|
1610
|
+
// src/client/GateClient.ts
|
|
1611
|
+
var GateClient = class {
|
|
1612
|
+
config;
|
|
1613
|
+
httpClient;
|
|
1614
|
+
hmacSigner;
|
|
1615
|
+
apiKeyAuth;
|
|
1616
|
+
stepUpPoller;
|
|
1617
|
+
circuitBreaker;
|
|
1618
|
+
metrics;
|
|
1619
|
+
heartbeatManager;
|
|
1620
|
+
mode;
|
|
1621
|
+
onConnectionFailure;
|
|
1622
|
+
constructor(config) {
|
|
1623
|
+
this.config = config;
|
|
1624
|
+
const envMode = process.env.GATE_MODE;
|
|
1625
|
+
this.mode = envMode || config.mode || "SHADOW";
|
|
1626
|
+
if (config.onConnectionFailure) {
|
|
1627
|
+
this.onConnectionFailure = config.onConnectionFailure;
|
|
1628
|
+
} else {
|
|
1629
|
+
this.onConnectionFailure = this.mode === "SHADOW" ? "FAIL_OPEN" : "FAIL_CLOSED";
|
|
1630
|
+
}
|
|
1631
|
+
if (config.auth.mode === "hmac") {
|
|
1632
|
+
this.hmacSigner = new HmacSigner({
|
|
1633
|
+
keyId: config.auth.keyId,
|
|
1634
|
+
secret: config.auth.secret
|
|
1635
|
+
});
|
|
1636
|
+
} else {
|
|
1637
|
+
this.apiKeyAuth = new ApiKeyAuth({
|
|
1638
|
+
apiKey: config.auth.apiKey
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
this.httpClient = new HttpClient({
|
|
1642
|
+
baseUrl: config.baseUrl,
|
|
1643
|
+
timeoutMs: config.timeoutMs,
|
|
1644
|
+
userAgent: config.userAgent,
|
|
1645
|
+
debug: config.debug
|
|
1646
|
+
});
|
|
1647
|
+
if (config.enableStepUp) {
|
|
1648
|
+
this.stepUpPoller = new StepUpPoller({
|
|
1649
|
+
httpClient: this.httpClient,
|
|
1650
|
+
tenantId: config.tenantId,
|
|
1651
|
+
pollingIntervalMs: config.stepUp?.pollingIntervalMs,
|
|
1652
|
+
maxWaitMs: config.stepUp?.maxWaitMs
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
if (config.circuitBreaker) {
|
|
1656
|
+
this.circuitBreaker = new CircuitBreaker(config.circuitBreaker);
|
|
1657
|
+
}
|
|
1658
|
+
this.metrics = new MetricsCollector();
|
|
1659
|
+
if (config.onMetrics) {
|
|
1660
|
+
this.metrics.registerHook(config.onMetrics);
|
|
1661
|
+
}
|
|
1662
|
+
if (config.local) {
|
|
1663
|
+
console.warn("[GATE CLIENT] LOCAL MODE ENABLED - Auth, heartbeat, and break-glass are disabled");
|
|
1664
|
+
this.heartbeatManager = null;
|
|
1665
|
+
} else {
|
|
1666
|
+
const heartbeatApiKey = config.heartbeatApiKey ?? (typeof process !== "undefined" ? process.env.GATE_HEARTBEAT_KEY : void 0);
|
|
1667
|
+
if (!heartbeatApiKey || heartbeatApiKey.length === 0) {
|
|
1668
|
+
throw new Error(
|
|
1669
|
+
"GATE_HEARTBEAT_KEY environment variable or heartbeatApiKey in config is required for heartbeat authentication. Set GATE_HEARTBEAT_KEY in your environment or pass heartbeatApiKey in GateClientConfig."
|
|
1670
|
+
);
|
|
1671
|
+
}
|
|
1672
|
+
let controlPlaneUrl = config.baseUrl;
|
|
1673
|
+
if (controlPlaneUrl.includes("/defense")) {
|
|
1674
|
+
controlPlaneUrl = controlPlaneUrl.split("/defense")[0];
|
|
1675
|
+
}
|
|
1676
|
+
if (config.controlPlaneUrl) {
|
|
1677
|
+
controlPlaneUrl = config.controlPlaneUrl;
|
|
1678
|
+
}
|
|
1679
|
+
const heartbeatHttpClient = new HttpClient({
|
|
1680
|
+
baseUrl: controlPlaneUrl,
|
|
1681
|
+
timeoutMs: 5e3,
|
|
1682
|
+
// 5s timeout for heartbeat
|
|
1683
|
+
userAgent: config.userAgent
|
|
1684
|
+
});
|
|
1685
|
+
const initialSignerId = config.signerId ?? "trading-bot-signer";
|
|
1686
|
+
this.heartbeatManager = new HeartbeatManager({
|
|
1687
|
+
httpClient: heartbeatHttpClient,
|
|
1688
|
+
tenantId: config.tenantId,
|
|
1689
|
+
signerId: initialSignerId,
|
|
1690
|
+
environment: config.environment ?? "prod",
|
|
1691
|
+
refreshIntervalSeconds: config.heartbeatRefreshIntervalSeconds ?? 10,
|
|
1692
|
+
apiKey: heartbeatApiKey
|
|
1693
|
+
});
|
|
1694
|
+
this.heartbeatManager.start();
|
|
1695
|
+
}
|
|
1696
|
+
if (!config.local) {
|
|
1697
|
+
const enforcementMode = config.enforcementMode || "SOFT";
|
|
1698
|
+
const allowInsecureKmsSignPermission = config.allowInsecureKmsSignPermission ?? enforcementMode === "SOFT";
|
|
1699
|
+
const riskChecker = new IamPermissionRiskChecker({
|
|
1700
|
+
tenantId: config.tenantId,
|
|
1701
|
+
signerId: config.signerId,
|
|
1702
|
+
environment: config.environment,
|
|
1703
|
+
enforcementMode,
|
|
1704
|
+
allowInsecureKmsSignPermission,
|
|
1705
|
+
kmsKeyIds: config.kmsKeyIds
|
|
1706
|
+
});
|
|
1707
|
+
riskChecker.checkSync();
|
|
1708
|
+
this.performIamRiskCheckAsync(riskChecker, enforcementMode).catch((error) => {
|
|
1709
|
+
if (enforcementMode === "SOFT" || allowInsecureKmsSignPermission) {
|
|
1710
|
+
console.warn("[GATE CLIENT] Async IAM risk check warning:", error instanceof Error ? error.message : String(error));
|
|
1711
|
+
} else {
|
|
1712
|
+
console.error("[GATE CLIENT] Async IAM risk check found risk after initialization:", error);
|
|
1713
|
+
}
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
/**
|
|
1718
|
+
* Whether the SDK requires a decision token for ALLOW before sign (ENFORCE/HARD).
|
|
1719
|
+
* Env GATE_REQUIRE_DECISION_TOKEN overrides config.
|
|
1720
|
+
*/
|
|
1721
|
+
getRequireDecisionToken() {
|
|
1722
|
+
if (typeof process !== "undefined" && process.env.GATE_REQUIRE_DECISION_TOKEN !== void 0) {
|
|
1723
|
+
return process.env.GATE_REQUIRE_DECISION_TOKEN === "true" || process.env.GATE_REQUIRE_DECISION_TOKEN === "1";
|
|
1724
|
+
}
|
|
1725
|
+
return this.config.requireDecisionToken ?? (this.mode === "ENFORCE" || this.config.enforcementMode === "HARD");
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* Perform async IAM permission risk check (non-blocking)
|
|
1729
|
+
*
|
|
1730
|
+
* Performs async IAM simulation check in background.
|
|
1731
|
+
* Logs warnings but doesn't block (initialization already completed).
|
|
1732
|
+
*/
|
|
1733
|
+
async performIamRiskCheckAsync(riskChecker, enforcementMode) {
|
|
1734
|
+
try {
|
|
1735
|
+
await riskChecker.check();
|
|
1736
|
+
} catch (error) {
|
|
1737
|
+
console.warn("[GATE CLIENT] Async IAM risk check warning:", error instanceof Error ? error.message : String(error));
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* Evaluate a transaction defense request
|
|
1742
|
+
*
|
|
1743
|
+
* Implements:
|
|
1744
|
+
* - Shadow Mode (SHADOW: monitor-only, ENFORCE: enforce decisions)
|
|
1745
|
+
* - Connection failure strategy (FAIL_OPEN vs FAIL_CLOSED)
|
|
1746
|
+
* - Circuit breaker protection
|
|
1747
|
+
* - Fail-safe modes (ALLOW_ON_TIMEOUT, BLOCK_ON_TIMEOUT, BLOCK_ON_ANOMALY)
|
|
1748
|
+
* - Metrics collection
|
|
1749
|
+
* - Error handling (BLOCK → BlockIntelBlockedError, REQUIRE_STEP_UP → BlockIntelStepUpRequiredError)
|
|
1750
|
+
*/
|
|
1751
|
+
async evaluate(req, opts) {
|
|
1752
|
+
const requestId = opts?.requestId ?? v4();
|
|
1753
|
+
const timestampMs = req.timestampMs ?? nowMs();
|
|
1754
|
+
const startTime = Date.now();
|
|
1755
|
+
const failSafeMode = this.config.failSafeMode ?? "ALLOW_ON_TIMEOUT";
|
|
1756
|
+
const evaluationMode = this.config.evaluationMode ?? "BLOCKING";
|
|
1757
|
+
const requestMode = req.mode || this.mode;
|
|
1758
|
+
const requireToken = this.getRequireDecisionToken();
|
|
1759
|
+
const executeRequest = async () => {
|
|
1760
|
+
if (!this.config.local && this.heartbeatManager && req.signingContext?.signerId) {
|
|
1761
|
+
this.heartbeatManager.updateSignerId(req.signingContext.signerId);
|
|
1762
|
+
}
|
|
1763
|
+
let heartbeatToken = null;
|
|
1764
|
+
if (!this.config.local && this.heartbeatManager) {
|
|
1765
|
+
heartbeatToken = this.heartbeatManager.getToken();
|
|
1766
|
+
if (!heartbeatToken) {
|
|
1767
|
+
const maxWaitMs = 2e3;
|
|
1768
|
+
const startTime2 = Date.now();
|
|
1769
|
+
while (!heartbeatToken && Date.now() - startTime2 < maxWaitMs) {
|
|
1770
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1771
|
+
heartbeatToken = this.heartbeatManager.getToken();
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
if (!heartbeatToken) {
|
|
1775
|
+
throw new GateError(
|
|
1776
|
+
"HEARTBEAT_MISSING" /* HEARTBEAT_MISSING */,
|
|
1777
|
+
"Signing blocked: Heartbeat token is missing or expired. Gate must be alive and enforcing policy."
|
|
1778
|
+
);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
const txIntent = { ...req.txIntent };
|
|
1782
|
+
if (txIntent.to && !txIntent.toAddress) {
|
|
1783
|
+
txIntent.toAddress = txIntent.to;
|
|
1784
|
+
delete txIntent.to;
|
|
1785
|
+
}
|
|
1786
|
+
if (!txIntent.networkFamily && txIntent.chainId) {
|
|
1787
|
+
txIntent.networkFamily = "EVM";
|
|
1788
|
+
}
|
|
1789
|
+
if (txIntent.from && !txIntent.fromAddress) {
|
|
1790
|
+
delete txIntent.from;
|
|
1791
|
+
}
|
|
1792
|
+
const signingContext = {
|
|
1793
|
+
...req.signingContext,
|
|
1794
|
+
actorPrincipal: req.signingContext?.actorPrincipal ?? req.signingContext?.signerId ?? "gate-sdk-client",
|
|
1795
|
+
signerId: req.signingContext?.signerId ?? req.signingContext?.actorPrincipal ?? "gate-sdk-client"
|
|
1796
|
+
};
|
|
1797
|
+
if (heartbeatToken) {
|
|
1798
|
+
signingContext.heartbeatToken = heartbeatToken;
|
|
1799
|
+
}
|
|
1800
|
+
const provenance = ProvenanceProvider.getProvenance();
|
|
1801
|
+
if (provenance) {
|
|
1802
|
+
signingContext.caller = {
|
|
1803
|
+
repo: provenance.repo,
|
|
1804
|
+
workflow: provenance.workflow,
|
|
1805
|
+
ref: provenance.ref,
|
|
1806
|
+
actor: provenance.actor,
|
|
1807
|
+
attestation: provenance.attestation
|
|
1808
|
+
};
|
|
1809
|
+
}
|
|
1810
|
+
let body = {
|
|
1811
|
+
tenantId: this.config.tenantId,
|
|
1812
|
+
requestId,
|
|
1813
|
+
timestampMs,
|
|
1814
|
+
txIntent,
|
|
1815
|
+
signingContext,
|
|
1816
|
+
// Add SDK info (required by Hot Path validation)
|
|
1817
|
+
sdk: {
|
|
1818
|
+
name: "gate-sdk",
|
|
1819
|
+
version: "0.1.0"
|
|
1820
|
+
},
|
|
1821
|
+
mode: requestMode,
|
|
1822
|
+
onConnectionFailure: this.onConnectionFailure
|
|
1823
|
+
};
|
|
1824
|
+
if (req.simulate === true) {
|
|
1825
|
+
body.simulate = true;
|
|
1826
|
+
}
|
|
1827
|
+
if (!this.config.local && this.config.breakglassToken) {
|
|
1828
|
+
signingContext.breakglassToken = this.config.breakglassToken;
|
|
1829
|
+
}
|
|
1830
|
+
let headers = {};
|
|
1831
|
+
if (this.config.local) {
|
|
1832
|
+
headers = {
|
|
1833
|
+
"Content-Type": "application/json"
|
|
1834
|
+
};
|
|
1835
|
+
console.log("[GATE CLIENT] LOCAL MODE - Skipping authentication");
|
|
1836
|
+
} else if (this.hmacSigner) {
|
|
1837
|
+
const { canonicalizeJson: canonicalizeJson2 } = await Promise.resolve().then(() => (init_canonicalJson(), canonicalJson_exports));
|
|
1838
|
+
const canonicalBodyJson = canonicalizeJson2(body);
|
|
1839
|
+
const hmacHeaders = await this.hmacSigner.signRequest({
|
|
1840
|
+
method: "POST",
|
|
1841
|
+
path: "/defense/evaluate",
|
|
1842
|
+
tenantId: this.config.tenantId,
|
|
1843
|
+
timestampMs,
|
|
1844
|
+
requestId,
|
|
1845
|
+
body
|
|
1846
|
+
// Pass original body - HmacSigner will canonicalize it internally
|
|
1847
|
+
});
|
|
1848
|
+
headers = { ...hmacHeaders };
|
|
1849
|
+
body.__canonicalJson = canonicalBodyJson;
|
|
1850
|
+
} else if (this.apiKeyAuth) {
|
|
1851
|
+
const apiKeyHeaders = this.apiKeyAuth.createHeaders({
|
|
1852
|
+
tenantId: this.config.tenantId,
|
|
1853
|
+
timestampMs,
|
|
1854
|
+
requestId
|
|
1855
|
+
});
|
|
1856
|
+
headers = { ...apiKeyHeaders };
|
|
1857
|
+
} else {
|
|
1858
|
+
throw new Error("No authentication configured");
|
|
1859
|
+
}
|
|
1860
|
+
const apiResponse = await this.httpClient.request({
|
|
1861
|
+
method: "POST",
|
|
1862
|
+
path: "/defense/evaluate",
|
|
1863
|
+
headers,
|
|
1864
|
+
body,
|
|
1865
|
+
requestId
|
|
1866
|
+
});
|
|
1867
|
+
let responseData;
|
|
1868
|
+
if (apiResponse.success === true && apiResponse.data) {
|
|
1869
|
+
responseData = apiResponse.data;
|
|
1870
|
+
} else if (apiResponse.success === false && apiResponse.error) {
|
|
1871
|
+
const error = apiResponse.error;
|
|
1872
|
+
throw new GateError(
|
|
1873
|
+
error.code || "SERVER_ERROR" /* SERVER_ERROR */,
|
|
1874
|
+
error.message || "Request failed",
|
|
1875
|
+
{
|
|
1876
|
+
status: error.status,
|
|
1877
|
+
correlationId: error.correlationId,
|
|
1878
|
+
requestId,
|
|
1879
|
+
details: error
|
|
1880
|
+
}
|
|
1881
|
+
);
|
|
1882
|
+
} else if (apiResponse.decision) {
|
|
1883
|
+
responseData = apiResponse;
|
|
1884
|
+
} else {
|
|
1885
|
+
throw new GateError(
|
|
1886
|
+
"INVALID_RESPONSE" /* INVALID_RESPONSE */,
|
|
1887
|
+
"Invalid response format: expected { success: true, data: { ... } } or unwrapped response",
|
|
1888
|
+
{
|
|
1889
|
+
requestId,
|
|
1890
|
+
details: apiResponse
|
|
1891
|
+
}
|
|
1892
|
+
);
|
|
1893
|
+
}
|
|
1894
|
+
const metadata = responseData.metadata || {};
|
|
1895
|
+
const simulationData = metadata.simulation;
|
|
1896
|
+
const result = {
|
|
1897
|
+
decision: responseData.decision,
|
|
1898
|
+
reasonCodes: responseData.reason_codes ?? responseData.reasonCodes ?? [],
|
|
1899
|
+
policyVersion: responseData.policy_version ?? responseData.policyVersion,
|
|
1900
|
+
correlationId: responseData.correlation_id ?? responseData.correlationId,
|
|
1901
|
+
decisionId: responseData.decision_id ?? responseData.decisionId,
|
|
1902
|
+
decisionToken: responseData.decision_token ?? responseData.decisionToken,
|
|
1903
|
+
expiresAt: responseData.expires_at ?? responseData.expiresAt,
|
|
1904
|
+
txDigest: responseData.tx_digest ?? responseData.txDigest,
|
|
1905
|
+
stepUp: responseData.step_up ? {
|
|
1906
|
+
requestId: responseData.step_up.request_id ?? (responseData.stepUp?.requestId ?? ""),
|
|
1907
|
+
ttlSeconds: responseData.step_up.ttl_seconds ?? responseData.stepUp?.ttlSeconds
|
|
1908
|
+
} : responseData.stepUp,
|
|
1909
|
+
enforced: responseData.enforced ?? requestMode === "ENFORCE",
|
|
1910
|
+
shadowWouldBlock: responseData.shadow_would_block ?? responseData.shadowWouldBlock ?? false,
|
|
1911
|
+
mode: responseData.mode ?? requestMode,
|
|
1912
|
+
...simulationData ? {
|
|
1913
|
+
simulation: {
|
|
1914
|
+
willRevert: simulationData.willRevert ?? simulationData.will_revert ?? false,
|
|
1915
|
+
gasUsed: simulationData.gasUsed ?? simulationData.gas_used,
|
|
1916
|
+
balanceChanges: simulationData.balanceChanges ?? simulationData.balance_changes,
|
|
1917
|
+
errorReason: simulationData.errorReason ?? simulationData.error_reason
|
|
1918
|
+
},
|
|
1919
|
+
simulationLatencyMs: metadata.simulationLatencyMs ?? metadata.simulation_latency_ms
|
|
1920
|
+
} : {},
|
|
1921
|
+
metadata: {
|
|
1922
|
+
evaluationLatencyMs: metadata.evaluationLatencyMs ?? metadata.evaluation_latency_ms,
|
|
1923
|
+
policyHash: metadata.policyHash ?? metadata.policy_hash,
|
|
1924
|
+
snapshotVersion: metadata.snapshotVersion ?? metadata.snapshot_version
|
|
1925
|
+
}
|
|
1926
|
+
};
|
|
1927
|
+
const latencyMs = Date.now() - startTime;
|
|
1928
|
+
const expectedPolicyHash = this.config.expectedPolicyHash;
|
|
1929
|
+
const expectedSnapshotVersion = this.config.expectedSnapshotVersion;
|
|
1930
|
+
if (expectedPolicyHash != null && result.metadata?.policyHash !== expectedPolicyHash) {
|
|
1931
|
+
if (this.config.debug) {
|
|
1932
|
+
console.warn("[GATE SDK] Policy hash mismatch (pinning)", {
|
|
1933
|
+
expected: expectedPolicyHash,
|
|
1934
|
+
received: result.metadata?.policyHash,
|
|
1935
|
+
requestId
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
this.metrics.recordRequest("BLOCK", latencyMs);
|
|
1939
|
+
throw new BlockIntelBlockedError(
|
|
1940
|
+
"POLICY_HASH_MISMATCH",
|
|
1941
|
+
result.decisionId ?? requestId,
|
|
1942
|
+
result.correlationId,
|
|
1943
|
+
requestId
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1946
|
+
if (expectedSnapshotVersion != null && result.metadata?.snapshotVersion !== void 0 && result.metadata.snapshotVersion !== expectedSnapshotVersion) {
|
|
1947
|
+
if (this.config.debug) {
|
|
1948
|
+
console.warn("[GATE SDK] Snapshot version mismatch (pinning)", {
|
|
1949
|
+
expected: expectedSnapshotVersion,
|
|
1950
|
+
received: result.metadata?.snapshotVersion,
|
|
1951
|
+
requestId
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
this.metrics.recordRequest("BLOCK", latencyMs);
|
|
1955
|
+
throw new BlockIntelBlockedError(
|
|
1956
|
+
"SNAPSHOT_VERSION_MISMATCH",
|
|
1957
|
+
result.decisionId ?? requestId,
|
|
1958
|
+
result.correlationId,
|
|
1959
|
+
requestId
|
|
1960
|
+
);
|
|
1961
|
+
}
|
|
1962
|
+
if (requireToken && requestMode === "ENFORCE" && result.decision === "ALLOW" && !this.config.local) {
|
|
1963
|
+
if (!result.decisionToken || !result.txDigest) {
|
|
1964
|
+
this.metrics.recordRequest("BLOCK", latencyMs);
|
|
1965
|
+
throw new BlockIntelBlockedError(
|
|
1966
|
+
"DECISION_TOKEN_MISSING",
|
|
1967
|
+
result.decisionId ?? requestId,
|
|
1968
|
+
result.correlationId,
|
|
1969
|
+
requestId
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
const nowSec = Math.floor(Date.now() / 1e3);
|
|
1973
|
+
if (result.expiresAt != null && result.expiresAt < nowSec - 5) {
|
|
1974
|
+
this.metrics.recordRequest("BLOCK", latencyMs);
|
|
1975
|
+
throw new BlockIntelBlockedError(
|
|
1976
|
+
"DECISION_TOKEN_EXPIRED",
|
|
1977
|
+
result.decisionId ?? requestId,
|
|
1978
|
+
result.correlationId,
|
|
1979
|
+
requestId
|
|
1980
|
+
);
|
|
1981
|
+
}
|
|
1982
|
+
const publicKeyPem = this.config.decisionTokenPublicKey;
|
|
1983
|
+
if (publicKeyPem && result.decisionToken) {
|
|
1984
|
+
const { decodeJwtUnsafe: decodeJwtUnsafe2, verifyDecisionTokenRs256: verifyDecisionTokenRs2562 } = await Promise.resolve().then(() => (init_decisionTokenVerify(), decisionTokenVerify_exports));
|
|
1985
|
+
const decoded = decodeJwtUnsafe2(result.decisionToken);
|
|
1986
|
+
if (decoded && (decoded.header.alg || "").toUpperCase() === "RS256") {
|
|
1987
|
+
const resolvedPem = publicKeyPem.startsWith("-----") ? publicKeyPem : Buffer.from(publicKeyPem, "base64").toString("utf8");
|
|
1988
|
+
const verified = verifyDecisionTokenRs2562(result.decisionToken, resolvedPem);
|
|
1989
|
+
if (verified === null) {
|
|
1990
|
+
this.metrics.recordRequest("BLOCK", latencyMs);
|
|
1991
|
+
throw new BlockIntelBlockedError(
|
|
1992
|
+
"DECISION_TOKEN_INVALID",
|
|
1993
|
+
result.decisionId ?? requestId,
|
|
1994
|
+
result.correlationId,
|
|
1995
|
+
requestId
|
|
1996
|
+
);
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
const signerId = signingContext?.signerId ?? req.signingContext?.signerId;
|
|
2001
|
+
const fromAddress = txIntent.fromAddress ?? txIntent.from;
|
|
2002
|
+
const binding = buildTxBindingObject(txIntent, signerId, void 0, void 0, fromAddress);
|
|
2003
|
+
const computedDigest = computeTxDigest(binding);
|
|
2004
|
+
if (computedDigest !== result.txDigest) {
|
|
2005
|
+
this.metrics.recordRequest("BLOCK", latencyMs);
|
|
2006
|
+
throw new BlockIntelBlockedError(
|
|
2007
|
+
"DECISION_TOKEN_DIGEST_MISMATCH",
|
|
2008
|
+
result.decisionId ?? requestId,
|
|
2009
|
+
result.correlationId,
|
|
2010
|
+
requestId
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
if (result.decision === "BLOCK") {
|
|
2015
|
+
if (requestMode === "SOFT_ENFORCE") {
|
|
2016
|
+
console.warn("[SOFT ENFORCE] Policy violation detected - app can override", {
|
|
2017
|
+
requestId,
|
|
2018
|
+
reasonCodes: result.reasonCodes
|
|
2019
|
+
});
|
|
2020
|
+
this.metrics.recordRequest("BLOCK", latencyMs);
|
|
2021
|
+
return {
|
|
2022
|
+
...result,
|
|
2023
|
+
decision: "BLOCK",
|
|
2024
|
+
enforced: false,
|
|
2025
|
+
mode: "SOFT_ENFORCE",
|
|
2026
|
+
warning: "Policy violation detected. Override at your own risk."
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
if (requestMode === "SHADOW") {
|
|
2030
|
+
console.warn("[GATE SHADOW MODE] Would have blocked transaction", {
|
|
2031
|
+
requestId,
|
|
2032
|
+
reasonCodes: result.reasonCodes,
|
|
2033
|
+
correlationId: result.correlationId,
|
|
2034
|
+
tenantId: this.config.tenantId,
|
|
2035
|
+
signerId: req.signingContext?.signerId
|
|
2036
|
+
});
|
|
2037
|
+
this.metrics.recordRequest("WOULD_BLOCK", latencyMs);
|
|
2038
|
+
return {
|
|
2039
|
+
...result,
|
|
2040
|
+
decision: "ALLOW",
|
|
2041
|
+
enforced: false,
|
|
2042
|
+
shadowWouldBlock: true
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
const receiptId = responseData.decision_id || requestId;
|
|
2046
|
+
const reasonCode = result.reasonCodes[0] || "POLICY_VIOLATION";
|
|
2047
|
+
this.metrics.recordRequest("BLOCK", latencyMs);
|
|
2048
|
+
throw new BlockIntelBlockedError(reasonCode, receiptId, result.correlationId, requestId);
|
|
2049
|
+
}
|
|
2050
|
+
if (result.decision === "REQUIRE_STEP_UP") {
|
|
2051
|
+
if (this.config.enableStepUp && this.stepUpPoller && result.stepUp) {
|
|
2052
|
+
const stepUpRequestId = result.stepUp.requestId || requestId;
|
|
2053
|
+
const expiresAtMs = responseData.step_up?.expires_at_ms;
|
|
2054
|
+
const statusUrl = `/defense/stepup/status?tenantId=${this.config.tenantId}&requestId=${stepUpRequestId}`;
|
|
2055
|
+
this.metrics.recordRequest("REQUIRE_STEP_UP", latencyMs);
|
|
2056
|
+
throw new BlockIntelStepUpRequiredError(stepUpRequestId, statusUrl, expiresAtMs, requestId);
|
|
2057
|
+
} else {
|
|
2058
|
+
const receiptId = responseData.decision_id || requestId;
|
|
2059
|
+
const reasonCode = "STEPUP_REQUIRED";
|
|
2060
|
+
this.metrics.recordRequest("BLOCK", latencyMs);
|
|
2061
|
+
throw new BlockIntelBlockedError(reasonCode, receiptId, result.correlationId, requestId);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
this.metrics.recordRequest("ALLOW", latencyMs);
|
|
2065
|
+
return result;
|
|
2066
|
+
};
|
|
2067
|
+
if (evaluationMode === "FIRE_AND_FORGET") {
|
|
2068
|
+
executeRequest().then((res) => {
|
|
2069
|
+
if (res.decision === "BLOCK" || res.shadowWouldBlock) {
|
|
2070
|
+
console.warn("[FIRE-AND-FORGET] Would have blocked:", res.reasonCodes);
|
|
2071
|
+
}
|
|
2072
|
+
this.metrics.recordRequest(res.decision === "ALLOW" ? "ALLOW" : "WOULD_BLOCK", Date.now() - startTime);
|
|
2073
|
+
}).catch((err) => {
|
|
2074
|
+
console.error("[FIRE-AND-FORGET] Attestation failed:", err);
|
|
2075
|
+
this.metrics.recordError();
|
|
2076
|
+
});
|
|
2077
|
+
return {
|
|
2078
|
+
decision: "ALLOW",
|
|
2079
|
+
decisionId: requestId,
|
|
2080
|
+
correlationId: requestId,
|
|
2081
|
+
reasonCodes: [],
|
|
2082
|
+
enforced: false,
|
|
2083
|
+
mode: requestMode,
|
|
2084
|
+
fireAndForget: true
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
try {
|
|
2088
|
+
if (this.circuitBreaker) {
|
|
2089
|
+
return await this.circuitBreaker.execute(executeRequest);
|
|
2090
|
+
}
|
|
2091
|
+
return await executeRequest();
|
|
2092
|
+
} catch (error) {
|
|
2093
|
+
if (error instanceof CircuitBreakerOpenError) {
|
|
2094
|
+
this.metrics.recordCircuitBreakerOpen();
|
|
2095
|
+
const failSafeResult = this.handleFailSafe(failSafeMode, error, requestId);
|
|
2096
|
+
if (failSafeResult) {
|
|
2097
|
+
return failSafeResult;
|
|
2098
|
+
}
|
|
2099
|
+
throw error;
|
|
2100
|
+
}
|
|
2101
|
+
if (error instanceof GateError && (error.code === "UNAUTHORIZED" /* UNAUTHORIZED */ || error.code === "FORBIDDEN" /* FORBIDDEN */)) {
|
|
2102
|
+
this.metrics.recordError();
|
|
2103
|
+
throw new BlockIntelAuthError(
|
|
2104
|
+
error.message,
|
|
2105
|
+
error.status || 401,
|
|
2106
|
+
requestId
|
|
2107
|
+
);
|
|
2108
|
+
}
|
|
2109
|
+
const isConnectionFailure = error instanceof GateError && (error.code === "TIMEOUT" /* TIMEOUT */ || error.code === "SERVER_ERROR" /* SERVER_ERROR */) || error instanceof BlockIntelUnavailableError || error?.code === "ECONNREFUSED" || error?.code === "ENOTFOUND" || error?.code === "ETIMEDOUT";
|
|
2110
|
+
if (isConnectionFailure) {
|
|
2111
|
+
this.metrics.recordTimeout();
|
|
2112
|
+
if (this.onConnectionFailure === "FAIL_OPEN") {
|
|
2113
|
+
console.error("[GATE CONNECTION FAILURE] FAIL_OPEN mode - allowing transaction", {
|
|
2114
|
+
requestId,
|
|
2115
|
+
error: error.message,
|
|
2116
|
+
tenantId: this.config.tenantId,
|
|
2117
|
+
mode: requestMode
|
|
2118
|
+
});
|
|
2119
|
+
console.warn("[GATE SDK] X-BlockIntel-Degraded: true (reason: fail_open)");
|
|
2120
|
+
this.metrics.recordRequest("FAIL_OPEN", Date.now() - startTime);
|
|
2121
|
+
return {
|
|
2122
|
+
decision: "ALLOW",
|
|
2123
|
+
reasonCodes: ["GATE_HOTPATH_UNAVAILABLE"],
|
|
2124
|
+
correlationId: requestId,
|
|
2125
|
+
enforced: false,
|
|
2126
|
+
mode: requestMode
|
|
2127
|
+
};
|
|
2128
|
+
} else {
|
|
2129
|
+
throw new BlockIntelUnavailableError(
|
|
2130
|
+
`Signing blocked: Gate hot path unreachable (fail-closed). ${error.message}`,
|
|
2131
|
+
requestId
|
|
2132
|
+
);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
if (error instanceof GateError && error.code === "TIMEOUT" /* TIMEOUT */) {
|
|
2136
|
+
this.metrics.recordTimeout();
|
|
2137
|
+
const failSafeResult = this.handleFailSafe(failSafeMode, error, requestId);
|
|
2138
|
+
if (failSafeResult) {
|
|
2139
|
+
return failSafeResult;
|
|
2140
|
+
}
|
|
2141
|
+
throw new BlockIntelUnavailableError(`Service timeout: ${error.message}`, requestId);
|
|
2142
|
+
}
|
|
2143
|
+
if (error instanceof GateError && error.code === "SERVER_ERROR" /* SERVER_ERROR */) {
|
|
2144
|
+
this.metrics.recordError();
|
|
2145
|
+
const failSafeResult = this.handleFailSafe(failSafeMode, error, requestId);
|
|
2146
|
+
if (failSafeResult) {
|
|
2147
|
+
return failSafeResult;
|
|
2148
|
+
}
|
|
2149
|
+
throw error;
|
|
2150
|
+
}
|
|
2151
|
+
if (error instanceof GateError && error.code === "RATE_LIMITED" /* RATE_LIMITED */) {
|
|
2152
|
+
console.warn("[GATE SDK] X-BlockIntel-Degraded: true (reason: 429)");
|
|
2153
|
+
throw error;
|
|
2154
|
+
}
|
|
2155
|
+
if (error instanceof BlockIntelBlockedError || error instanceof BlockIntelStepUpRequiredError) {
|
|
2156
|
+
throw error;
|
|
2157
|
+
}
|
|
2158
|
+
this.metrics.recordError();
|
|
2159
|
+
throw error;
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
/**
|
|
2163
|
+
* Handle fail-safe modes for timeouts/errors
|
|
2164
|
+
*/
|
|
2165
|
+
handleFailSafe(mode, error, requestId) {
|
|
2166
|
+
if (mode === "ALLOW_ON_TIMEOUT") {
|
|
2167
|
+
console.warn("[GATE SDK] X-BlockIntel-Degraded: true (reason: fail_safe_allow)");
|
|
2168
|
+
return {
|
|
2169
|
+
decision: "ALLOW",
|
|
2170
|
+
reasonCodes: ["FAIL_SAFE_ALLOW"],
|
|
2171
|
+
correlationId: requestId
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
if (mode === "BLOCK_ON_TIMEOUT") {
|
|
2175
|
+
return null;
|
|
2176
|
+
}
|
|
2177
|
+
if (mode === "BLOCK_ON_ANOMALY") {
|
|
2178
|
+
console.warn("[GATE SDK] X-BlockIntel-Degraded: true (reason: fail_safe_allow)");
|
|
2179
|
+
return {
|
|
2180
|
+
decision: "ALLOW",
|
|
2181
|
+
reasonCodes: ["FAIL_SAFE_ALLOW"],
|
|
2182
|
+
correlationId: requestId
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
return null;
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Get current metrics
|
|
2189
|
+
*/
|
|
2190
|
+
getMetrics() {
|
|
2191
|
+
return this.metrics.getMetrics();
|
|
2192
|
+
}
|
|
2193
|
+
/**
|
|
2194
|
+
* Get circuit breaker metrics (if enabled)
|
|
2195
|
+
*/
|
|
2196
|
+
getCircuitBreakerMetrics() {
|
|
2197
|
+
return this.circuitBreaker?.getMetrics() || null;
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* Get step-up status
|
|
2201
|
+
*/
|
|
2202
|
+
async getStepUpStatus(args) {
|
|
2203
|
+
if (!this.stepUpPoller) {
|
|
2204
|
+
throw new StepUpNotConfiguredError(args.requestId);
|
|
2205
|
+
}
|
|
2206
|
+
const tenantId = args.tenantId ?? this.config.tenantId;
|
|
2207
|
+
const poller = new StepUpPoller({
|
|
2208
|
+
httpClient: this.httpClient,
|
|
2209
|
+
tenantId,
|
|
2210
|
+
pollingIntervalMs: this.config.stepUp?.pollingIntervalMs,
|
|
2211
|
+
maxWaitMs: this.config.stepUp?.maxWaitMs
|
|
2212
|
+
});
|
|
2213
|
+
return poller.getStatus(args.requestId);
|
|
2214
|
+
}
|
|
2215
|
+
/**
|
|
2216
|
+
* Wait for step-up decision with polling
|
|
2217
|
+
*/
|
|
2218
|
+
async awaitStepUpDecision(args) {
|
|
2219
|
+
if (!this.stepUpPoller) {
|
|
2220
|
+
throw new StepUpNotConfiguredError(args.requestId);
|
|
2221
|
+
}
|
|
2222
|
+
return this.stepUpPoller.awaitDecision(args.requestId, {
|
|
2223
|
+
maxWaitMs: args.maxWaitMs ?? this.config.stepUp?.maxWaitMs,
|
|
2224
|
+
intervalMs: args.intervalMs ?? this.config.stepUp?.pollingIntervalMs
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
/**
|
|
2228
|
+
* Evaluate policy and sign in one call when decision is ALLOW.
|
|
2229
|
+
* Convenience for: evaluate → if ALLOW then sign → return { decision, signature }.
|
|
2230
|
+
*/
|
|
2231
|
+
async evaluateAndSign(params) {
|
|
2232
|
+
const decision = await this.evaluate({
|
|
2233
|
+
txIntent: params.txIntent,
|
|
2234
|
+
signingContext: params.signingContext
|
|
2235
|
+
});
|
|
2236
|
+
if (decision.decision === "ALLOW") {
|
|
2237
|
+
const signature = await params.signer.sign({
|
|
2238
|
+
keyId: params.keyId,
|
|
2239
|
+
message: params.message,
|
|
2240
|
+
algorithm: params.algorithm ?? "ECDSA_SHA_256"
|
|
2241
|
+
});
|
|
2242
|
+
return { decision, signature };
|
|
2243
|
+
}
|
|
2244
|
+
return { decision };
|
|
2245
|
+
}
|
|
2246
|
+
/**
|
|
2247
|
+
* Attest a completed signature (post-sign). Use when you want zero latency impact on signing
|
|
2248
|
+
* but still want an audit trail. Policy is evaluated against txIntent; returns ALLOW or
|
|
2249
|
+
* POLICY_VIOLATION_DETECTED. Cannot be used for enforcement (signature already created).
|
|
2250
|
+
*/
|
|
2251
|
+
async attestCompleted(req) {
|
|
2252
|
+
const requestId = v4();
|
|
2253
|
+
const timestampMs = nowMs();
|
|
2254
|
+
const txIntent = { ...req.txIntent };
|
|
2255
|
+
if (txIntent.to && !txIntent.toAddress) {
|
|
2256
|
+
txIntent.toAddress = txIntent.to;
|
|
2257
|
+
delete txIntent.to;
|
|
2258
|
+
}
|
|
2259
|
+
if (!txIntent.networkFamily && txIntent.chainId) txIntent.networkFamily = "EVM";
|
|
2260
|
+
const signingContext = {
|
|
2261
|
+
...req.signingContext,
|
|
2262
|
+
signerId: req.signingContext?.signerId ?? req.signature.signerId
|
|
2263
|
+
};
|
|
2264
|
+
const body = {
|
|
2265
|
+
tenantId: this.config.tenantId,
|
|
2266
|
+
requestId,
|
|
2267
|
+
timestampMs,
|
|
2268
|
+
txIntent,
|
|
2269
|
+
signature: req.signature,
|
|
2270
|
+
signingContext
|
|
2271
|
+
};
|
|
2272
|
+
let headers = { "Content-Type": "application/json" };
|
|
2273
|
+
if (this.config.local) ; else if (this.hmacSigner) {
|
|
2274
|
+
const { canonicalizeJson: canonicalizeJson2 } = await Promise.resolve().then(() => (init_canonicalJson(), canonicalJson_exports));
|
|
2275
|
+
const canonicalBodyJson = canonicalizeJson2(body);
|
|
2276
|
+
const hmacHeaders = await this.hmacSigner.signRequest({
|
|
2277
|
+
method: "POST",
|
|
2278
|
+
path: "/defense/attest-completed",
|
|
2279
|
+
tenantId: this.config.tenantId,
|
|
2280
|
+
timestampMs,
|
|
2281
|
+
requestId,
|
|
2282
|
+
body
|
|
2283
|
+
});
|
|
2284
|
+
headers = { ...hmacHeaders };
|
|
2285
|
+
body.__canonicalJson = canonicalBodyJson;
|
|
2286
|
+
} else if (this.apiKeyAuth) {
|
|
2287
|
+
const apiKeyHeaders = this.apiKeyAuth.createHeaders({
|
|
2288
|
+
tenantId: this.config.tenantId,
|
|
2289
|
+
timestampMs,
|
|
2290
|
+
requestId
|
|
2291
|
+
});
|
|
2292
|
+
headers = { ...apiKeyHeaders };
|
|
2293
|
+
} else {
|
|
2294
|
+
throw new Error("No authentication configured");
|
|
2295
|
+
}
|
|
2296
|
+
const apiResponse = await this.httpClient.request({
|
|
2297
|
+
method: "POST",
|
|
2298
|
+
path: "/defense/attest-completed",
|
|
2299
|
+
headers,
|
|
2300
|
+
body,
|
|
2301
|
+
requestId
|
|
2302
|
+
});
|
|
2303
|
+
if (apiResponse.success === true && apiResponse.data) {
|
|
2304
|
+
const data = apiResponse.data;
|
|
2305
|
+
if (data.decision === "POLICY_VIOLATION_DETECTED") {
|
|
2306
|
+
console.warn("[POST-SIGN ATTESTATION] Policy violation detected after signing", {
|
|
2307
|
+
requestId,
|
|
2308
|
+
reasonCodes: data.reasonCodes
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
return data;
|
|
2312
|
+
}
|
|
2313
|
+
if (apiResponse.error) {
|
|
2314
|
+
const err = apiResponse.error;
|
|
2315
|
+
throw new GateError(err.code || "SERVER_ERROR", err.message || "Request failed", {
|
|
2316
|
+
status: err.status,
|
|
2317
|
+
correlationId: err.correlationId,
|
|
2318
|
+
requestId
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
throw new GateError("INVALID_RESPONSE" /* INVALID_RESPONSE */, "Invalid response from attest-completed", { requestId });
|
|
2322
|
+
}
|
|
2323
|
+
/**
|
|
2324
|
+
* Wrap AWS SDK v3 KMS client to intercept SignCommand calls
|
|
2325
|
+
*
|
|
2326
|
+
* @param kmsClient - AWS SDK v3 KMSClient instance
|
|
2327
|
+
* @param options - Wrapper options
|
|
2328
|
+
* @returns Wrapped KMS client that enforces Gate policies
|
|
2329
|
+
*
|
|
2330
|
+
* @example
|
|
2331
|
+
* ```typescript
|
|
2332
|
+
* import { KMSClient } from '@aws-sdk/client-kms';
|
|
2333
|
+
*
|
|
2334
|
+
* const kms = new KMSClient({});
|
|
2335
|
+
* const protectedKms = gateClient.wrapKmsClient(kms);
|
|
2336
|
+
*
|
|
2337
|
+
* // Now SignCommand calls will be intercepted and evaluated by Gate
|
|
2338
|
+
* const result = await protectedKms.send(new SignCommand({ ... }));
|
|
2339
|
+
* ```
|
|
2340
|
+
*/
|
|
2341
|
+
wrapKmsClient(kmsClient, options) {
|
|
2342
|
+
return wrapKmsClient(kmsClient, this, options);
|
|
2343
|
+
}
|
|
2344
|
+
};
|
|
2345
|
+
|
|
2346
|
+
// src/pilot/pilotToken.ts
|
|
2347
|
+
function encodePilotToken(config) {
|
|
2348
|
+
const baseUrl = config.baseUrl ?? "https://gate.blockintelai.com";
|
|
2349
|
+
const mode = config.mode ?? "SHADOW";
|
|
2350
|
+
const payload = `${config.tenantId}:${config.keyId}:${config.hmacSecret}:${baseUrl}:${mode}`;
|
|
2351
|
+
return `gp_${Buffer.from(payload, "utf-8").toString("base64")}`;
|
|
2352
|
+
}
|
|
2353
|
+
function decodePilotToken(token) {
|
|
2354
|
+
if (!token || !token.startsWith("gp_")) {
|
|
2355
|
+
throw new Error("Invalid pilot token format. Expected GATE_PILOT_TOKEN=gp_<base64>. Get it from: https://gate.blockintelai.com/pilot/setup");
|
|
2356
|
+
}
|
|
2357
|
+
const decoded = Buffer.from(token.slice(3), "base64").toString("utf-8");
|
|
2358
|
+
const parts = decoded.split(":");
|
|
2359
|
+
if (parts.length < 3) {
|
|
2360
|
+
throw new Error("Invalid pilot token: missing tenantId, keyId, or secret");
|
|
2361
|
+
}
|
|
2362
|
+
const [tenantId, keyId, hmacSecret, baseUrl, mode] = parts;
|
|
2363
|
+
return {
|
|
2364
|
+
baseUrl: baseUrl || "https://gate.blockintelai.com",
|
|
2365
|
+
tenantId,
|
|
2366
|
+
auth: { mode: "hmac", keyId, secret: hmacSecret },
|
|
2367
|
+
mode: mode || "SHADOW"
|
|
2368
|
+
};
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
// src/pilot/attestOnly.ts
|
|
2372
|
+
var cachedClient = null;
|
|
2373
|
+
async function attestOnly(request) {
|
|
2374
|
+
const pilotToken = typeof process !== "undefined" ? process.env?.GATE_PILOT_TOKEN : void 0;
|
|
2375
|
+
if (!pilotToken) {
|
|
2376
|
+
throw new Error(
|
|
2377
|
+
"GATE_PILOT_TOKEN env var not set. Get it from: https://gate.blockintelai.com/pilot/setup"
|
|
2378
|
+
);
|
|
2379
|
+
}
|
|
2380
|
+
if (!cachedClient) {
|
|
2381
|
+
const config = decodePilotToken(pilotToken);
|
|
2382
|
+
cachedClient = new GateClient({
|
|
2383
|
+
...config,
|
|
2384
|
+
mode: "SHADOW",
|
|
2385
|
+
evaluationMode: "FIRE_AND_FORGET"
|
|
2386
|
+
});
|
|
2387
|
+
}
|
|
2388
|
+
return cachedClient.attestCompleted({
|
|
2389
|
+
signature: request.signature,
|
|
2390
|
+
txIntent: request.txIntent,
|
|
2391
|
+
signingContext: request.signingContext
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
export { attestOnly, decodePilotToken, encodePilotToken };
|
|
2396
|
+
//# sourceMappingURL=index.js.map
|
|
2397
|
+
//# sourceMappingURL=index.js.map
|