blockintel-gate-sdk 0.3.9 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -17
- package/dist/{contracts-KKk945Ox.d.cts → contracts-Dxb9vt_M.d.cts} +12 -0
- package/dist/{contracts-KKk945Ox.d.ts → contracts-Dxb9vt_M.d.ts} +12 -0
- package/dist/index.cjs +261 -77
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +70 -18
- package/dist/index.d.ts +70 -18
- package/dist/index.js +261 -78
- package/dist/index.js.map +1 -1
- package/dist/pilot/index.cjs +260 -77
- package/dist/pilot/index.cjs.map +1 -1
- package/dist/pilot/index.d.cts +1 -1
- package/dist/pilot/index.d.ts +1 -1
- package/dist/pilot/index.js +260 -77
- package/dist/pilot/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -408,11 +408,27 @@ The SDK includes a **Heartbeat Manager** that automatically acquires and refresh
|
|
|
408
408
|
|
|
409
409
|
### How It Works
|
|
410
410
|
|
|
411
|
-
1. **
|
|
412
|
-
2. **Token
|
|
413
|
-
3. **
|
|
414
|
-
4. **
|
|
415
|
-
5. **
|
|
411
|
+
1. **Per-Signer Token Cache**: The heartbeat manager maintains a `Map<signerId, SignerHeartbeatEntry>` so each signer gets its own cached token, refresh timer, and backoff state. Switching between signers never invalidates other signers' tokens.
|
|
412
|
+
2. **Automatic Token Acquisition**: When `getTokenForSigner(signerId)` is called, the manager returns the cached token immediately if valid, or acquires a new one on demand. The initial default signer is pre-warmed on `start()`.
|
|
413
|
+
3. **Per-Signer Refresh**: Each signer entry has its own background `setTimeout` timer that refreshes the token before expiry (default every 10 seconds + jitter + backoff on failure).
|
|
414
|
+
4. **LRU Eviction**: When the number of concurrent signer entries exceeds `maxSigners` (default: 20), the least-recently-used entry is evicted.
|
|
415
|
+
5. **Idle TTL Eviction**: A background timer (every 60s) evicts signer entries not used within `signerIdleTtlMs` (default: 5 minutes).
|
|
416
|
+
6. **Local Rate Limiting**: Per-signer guard prevents re-requesting within `localRateLimitMs` (default: 2.1s) to avoid hammering the Control Plane.
|
|
417
|
+
7. **Token Inclusion**: The heartbeat token is automatically included in the `signingContext` of every evaluation request.
|
|
418
|
+
|
|
419
|
+
### Multi-Signer Support
|
|
420
|
+
|
|
421
|
+
Trading desks running 3+ bot profiles no longer experience `HEARTBEAT_MISSING` errors when switching signers. Each signer's token is cached independently:
|
|
422
|
+
|
|
423
|
+
```typescript
|
|
424
|
+
// KMS wrapper automatically uses the correct signer from KeyId
|
|
425
|
+
const protectedKms = gate.wrapKmsClient(kmsClient);
|
|
426
|
+
|
|
427
|
+
// These calls use independent heartbeat tokens — no cross-invalidation
|
|
428
|
+
await protectedKms.sign({ KeyId: 'alias/bot-1', Message: tx1, ... });
|
|
429
|
+
await protectedKms.sign({ KeyId: 'alias/bot-2', Message: tx2, ... });
|
|
430
|
+
await protectedKms.sign({ KeyId: 'alias/bot-1', Message: tx3, ... }); // cache hit
|
|
431
|
+
```
|
|
416
432
|
|
|
417
433
|
### Configuration
|
|
418
434
|
|
|
@@ -426,8 +442,11 @@ const gate = new GateClient({
|
|
|
426
442
|
// Heartbeat manager uses baseUrl to infer Control Plane URL
|
|
427
443
|
// Or explicitly set controlPlaneUrl if different
|
|
428
444
|
controlPlaneUrl: 'https://control-plane.blockintelai.com', // Optional
|
|
429
|
-
signerId: 'my-signer-id', // Optional: signerId for heartbeat (if known upfront)
|
|
445
|
+
signerId: 'my-signer-id', // Optional: default signerId for heartbeat (if known upfront)
|
|
430
446
|
heartbeatRefreshIntervalSeconds: 10, // Optional: heartbeat refresh interval (default: 10s)
|
|
447
|
+
maxSigners: 20, // Optional: max concurrent signer entries (default: 20)
|
|
448
|
+
signerIdleTtlMs: 300000, // Optional: evict idle signers after this many ms (default: 5 min)
|
|
449
|
+
localRateLimitMs: 2100, // Optional: min ms between acquire attempts per signer (default: 2.1s)
|
|
431
450
|
});
|
|
432
451
|
```
|
|
433
452
|
|
|
@@ -458,23 +477,17 @@ try {
|
|
|
458
477
|
|
|
459
478
|
### Heartbeat Manager API
|
|
460
479
|
|
|
461
|
-
The
|
|
480
|
+
The primary API is `getTokenForSigner()`, which handles cache lookup, on-demand acquisition, and waiting:
|
|
462
481
|
|
|
463
482
|
```typescript
|
|
464
|
-
//
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
// Get current heartbeat token (if valid)
|
|
468
|
-
const token = gate.heartbeatManager.getToken();
|
|
469
|
-
|
|
470
|
-
// Update signer ID (called automatically when signer is known)
|
|
471
|
-
gate.heartbeatManager.updateSignerId('new-signer-id');
|
|
483
|
+
// Get token for a specific signer
|
|
484
|
+
const token = await gate.heartbeatManager.getTokenForSigner('my-signer-id', 2000);
|
|
472
485
|
|
|
473
|
-
// Stop heartbeat
|
|
486
|
+
// Stop heartbeat manager and clean up all timers (e.g., on shutdown)
|
|
474
487
|
gate.heartbeatManager.stop();
|
|
475
488
|
```
|
|
476
489
|
|
|
477
|
-
**Note**: The
|
|
490
|
+
**Note**: The KMS wrapper automatically calls `getTokenForSigner()` with the correct signer ID extracted from `KeyId`, so manual token management is typically not needed.
|
|
478
491
|
|
|
479
492
|
## Secret Rotation
|
|
480
493
|
|
|
@@ -169,6 +169,18 @@ interface DefenseEvaluateResponseV2 {
|
|
|
169
169
|
* Gate mode used for this evaluation
|
|
170
170
|
*/
|
|
171
171
|
mode?: GateMode;
|
|
172
|
+
/**
|
|
173
|
+
* Gate receipt (when HARD_KMS_GATEWAY (or HARD_KMS_ATTESTED, deprecated) or receipt requested). Required for KMS signing in receipt-required mode.
|
|
174
|
+
*/
|
|
175
|
+
receipt?: Record<string, unknown>;
|
|
176
|
+
/**
|
|
177
|
+
* Decision hash from receipt (SHA256 of canonical receipt payload). Used for receipt-required KMS flow.
|
|
178
|
+
*/
|
|
179
|
+
decisionHash?: string;
|
|
180
|
+
/**
|
|
181
|
+
* Receipt signature (HS256:base64). Used for receipt-required KMS flow.
|
|
182
|
+
*/
|
|
183
|
+
receiptSignature?: string;
|
|
172
184
|
/**
|
|
173
185
|
* Metadata (evaluation latency, simulation, policy hash for pinning)
|
|
174
186
|
*/
|
|
@@ -169,6 +169,18 @@ interface DefenseEvaluateResponseV2 {
|
|
|
169
169
|
* Gate mode used for this evaluation
|
|
170
170
|
*/
|
|
171
171
|
mode?: GateMode;
|
|
172
|
+
/**
|
|
173
|
+
* Gate receipt (when HARD_KMS_GATEWAY (or HARD_KMS_ATTESTED, deprecated) or receipt requested). Required for KMS signing in receipt-required mode.
|
|
174
|
+
*/
|
|
175
|
+
receipt?: Record<string, unknown>;
|
|
176
|
+
/**
|
|
177
|
+
* Decision hash from receipt (SHA256 of canonical receipt payload). Used for receipt-required KMS flow.
|
|
178
|
+
*/
|
|
179
|
+
decisionHash?: string;
|
|
180
|
+
/**
|
|
181
|
+
* Receipt signature (HS256:base64). Used for receipt-required KMS flow.
|
|
182
|
+
*/
|
|
183
|
+
receiptSignature?: string;
|
|
172
184
|
/**
|
|
173
185
|
* Metadata (evaluation latency, simulation, policy hash for pinning)
|
|
174
186
|
*/
|
package/dist/index.cjs
CHANGED
|
@@ -1047,13 +1047,21 @@ function computeTxDigest(binding) {
|
|
|
1047
1047
|
return crypto.createHash("sha256").update(canonical, "utf8").digest("hex");
|
|
1048
1048
|
}
|
|
1049
1049
|
|
|
1050
|
+
// src/metrics/GateMetricsSink.ts
|
|
1051
|
+
var noOpMetricsSink = {
|
|
1052
|
+
emit() {
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1050
1056
|
// src/kms/wrapAwsSdkV3KmsClient.ts
|
|
1051
1057
|
function wrapKmsClient(kmsClient, gateClient, options = {}) {
|
|
1052
1058
|
const defaultOptions = {
|
|
1053
1059
|
mode: options.mode || "enforce",
|
|
1060
|
+
requireReceiptForSign: options.requireReceiptForSign ?? false,
|
|
1054
1061
|
onDecision: options.onDecision || (() => {
|
|
1055
1062
|
}),
|
|
1056
|
-
extractTxIntent: options.extractTxIntent || defaultExtractTxIntent
|
|
1063
|
+
extractTxIntent: options.extractTxIntent || defaultExtractTxIntent,
|
|
1064
|
+
metricsSink: options.metricsSink ?? noOpMetricsSink
|
|
1057
1065
|
};
|
|
1058
1066
|
const wrapped = new Proxy(kmsClient, {
|
|
1059
1067
|
get(target, prop, receiver) {
|
|
@@ -1094,12 +1102,39 @@ function defaultExtractTxIntent(command) {
|
|
|
1094
1102
|
// Backward compatibility
|
|
1095
1103
|
};
|
|
1096
1104
|
}
|
|
1105
|
+
function buildMetricLabels(gateClient, command, signerId, txIntent) {
|
|
1106
|
+
const config = gateClient.config;
|
|
1107
|
+
const keyId = command.input?.KeyId ?? command.KeyId;
|
|
1108
|
+
return {
|
|
1109
|
+
tenantId: config?.tenantId,
|
|
1110
|
+
signerId: signerId || void 0,
|
|
1111
|
+
adoptionStage: config?.adoptionStage ?? process.env.GATE_ADOPTION_STAGE,
|
|
1112
|
+
env: config?.env ?? process.env.GATE_ENV ?? process.env.NODE_ENV,
|
|
1113
|
+
chain: txIntent.chainId != null ? String(txIntent.chainId) : txIntent.networkFamily,
|
|
1114
|
+
kmsKeyId: keyId,
|
|
1115
|
+
region: process.env.AWS_REGION
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
function emitMetric(sink, name, labels) {
|
|
1119
|
+
const event = { name, labels, timestampMs: Date.now() };
|
|
1120
|
+
try {
|
|
1121
|
+
const result = sink.emit(event);
|
|
1122
|
+
if (result && typeof result.catch === "function") {
|
|
1123
|
+
result.catch(() => {
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
} catch {
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1097
1129
|
async function handleSignCommand(command, originalClient, gateClient, options) {
|
|
1098
1130
|
const txIntent = options.extractTxIntent(command);
|
|
1099
1131
|
const signerId = command.input?.KeyId ?? command.KeyId ?? "unknown";
|
|
1100
|
-
gateClient
|
|
1101
|
-
|
|
1102
|
-
|
|
1132
|
+
const labels = buildMetricLabels(gateClient, command, signerId, txIntent);
|
|
1133
|
+
emitMetric(options.metricsSink, "sign_attempt_total", labels);
|
|
1134
|
+
let heartbeatToken;
|
|
1135
|
+
try {
|
|
1136
|
+
heartbeatToken = await gateClient.heartbeatManager.getTokenForSigner(signerId, 2e3);
|
|
1137
|
+
} catch {
|
|
1103
1138
|
throw new BlockIntelBlockedError(
|
|
1104
1139
|
"HEARTBEAT_MISSING",
|
|
1105
1140
|
void 0,
|
|
@@ -1123,6 +1158,28 @@ async function handleSignCommand(command, originalClient, gateClient, options) {
|
|
|
1123
1158
|
// Type assertion - txIntent may have extra fields
|
|
1124
1159
|
signingContext
|
|
1125
1160
|
});
|
|
1161
|
+
if (decision.decision === "ALLOW" && options.requireReceiptForSign) {
|
|
1162
|
+
const hasReceipt2 = decision.receipt != null || decision.decisionHash != null && decision.receiptSignature != null;
|
|
1163
|
+
if (!hasReceipt2) {
|
|
1164
|
+
emitMetric(options.metricsSink, "sign_blocked_missing_receipt_total", labels);
|
|
1165
|
+
options.onDecision("BLOCK", {
|
|
1166
|
+
error: new BlockIntelBlockedError(
|
|
1167
|
+
"RECEIPT_REQUIRED",
|
|
1168
|
+
decision.decisionId,
|
|
1169
|
+
decision.correlationId,
|
|
1170
|
+
void 0
|
|
1171
|
+
),
|
|
1172
|
+
signerId,
|
|
1173
|
+
command
|
|
1174
|
+
});
|
|
1175
|
+
throw new BlockIntelBlockedError(
|
|
1176
|
+
"RECEIPT_REQUIRED",
|
|
1177
|
+
decision.decisionId,
|
|
1178
|
+
decision.correlationId,
|
|
1179
|
+
void 0
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1126
1183
|
if (decision.decision === "ALLOW" && gateClient.getRequireDecisionToken() && decision.txDigest != null) {
|
|
1127
1184
|
const binding = buildTxBindingObject(
|
|
1128
1185
|
txIntent,
|
|
@@ -1151,6 +1208,11 @@ async function handleSignCommand(command, originalClient, gateClient, options) {
|
|
|
1151
1208
|
);
|
|
1152
1209
|
}
|
|
1153
1210
|
}
|
|
1211
|
+
const hasReceipt = decision.receipt != null || decision.decisionHash != null && decision.receiptSignature != null;
|
|
1212
|
+
if (hasReceipt) {
|
|
1213
|
+
emitMetric(options.metricsSink, "sign_success_with_receipt_total", labels);
|
|
1214
|
+
}
|
|
1215
|
+
emitMetric(options.metricsSink, "sign_success_total", labels);
|
|
1154
1216
|
options.onDecision("ALLOW", { decision, signerId, command });
|
|
1155
1217
|
if (options.mode === "dry-run") {
|
|
1156
1218
|
return await originalClient.send(new clientKms.SignCommand(command));
|
|
@@ -1211,7 +1273,7 @@ var ProvenanceProvider = class {
|
|
|
1211
1273
|
var HeartbeatManager = class {
|
|
1212
1274
|
httpClient;
|
|
1213
1275
|
tenantId;
|
|
1214
|
-
|
|
1276
|
+
defaultSignerId;
|
|
1215
1277
|
environment;
|
|
1216
1278
|
baseRefreshIntervalSeconds;
|
|
1217
1279
|
clientInstanceId;
|
|
@@ -1220,22 +1282,27 @@ var HeartbeatManager = class {
|
|
|
1220
1282
|
// SDK version for tracking
|
|
1221
1283
|
apiKey;
|
|
1222
1284
|
// x-gate-heartbeat-key for Control Plane auth
|
|
1223
|
-
|
|
1224
|
-
|
|
1285
|
+
signerEntries = /* @__PURE__ */ new Map();
|
|
1286
|
+
evictionTimer = null;
|
|
1225
1287
|
started = false;
|
|
1226
|
-
consecutiveFailures = 0;
|
|
1227
1288
|
maxBackoffSeconds = 30;
|
|
1228
1289
|
// Maximum backoff interval
|
|
1290
|
+
maxSigners;
|
|
1291
|
+
signerIdleTtlMs;
|
|
1292
|
+
localRateLimitMs;
|
|
1229
1293
|
constructor(options) {
|
|
1230
1294
|
this.httpClient = options.httpClient;
|
|
1231
1295
|
this.tenantId = options.tenantId;
|
|
1232
|
-
this.
|
|
1296
|
+
this.defaultSignerId = options.signerId;
|
|
1233
1297
|
this.environment = options.environment ?? "prod";
|
|
1234
1298
|
this.baseRefreshIntervalSeconds = options.refreshIntervalSeconds ?? 10;
|
|
1235
1299
|
this.apiKey = options.apiKey;
|
|
1236
1300
|
this.clientInstanceId = options.clientInstanceId || uuid.v4();
|
|
1237
1301
|
this.sdkVersion = options.sdkVersion || "1.0.0";
|
|
1238
1302
|
this.apiKey = options.apiKey;
|
|
1303
|
+
this.maxSigners = options.maxSigners ?? 20;
|
|
1304
|
+
this.signerIdleTtlMs = options.signerIdleTtlMs ?? 3e5;
|
|
1305
|
+
this.localRateLimitMs = options.localRateLimitMs ?? 2100;
|
|
1239
1306
|
}
|
|
1240
1307
|
/**
|
|
1241
1308
|
* Start background heartbeat refresher.
|
|
@@ -1246,46 +1313,56 @@ var HeartbeatManager = class {
|
|
|
1246
1313
|
return;
|
|
1247
1314
|
}
|
|
1248
1315
|
this.started = true;
|
|
1249
|
-
this.
|
|
1316
|
+
this.startEvictionTimer();
|
|
1317
|
+
this.getTokenForSigner(this.defaultSignerId, 0).catch((error) => {
|
|
1250
1318
|
console.error("[HEARTBEAT] Failed to acquire initial heartbeat:", error instanceof Error ? error.message : error);
|
|
1251
1319
|
});
|
|
1252
|
-
|
|
1320
|
+
}
|
|
1321
|
+
startEvictionTimer() {
|
|
1322
|
+
if (this.evictionTimer) clearInterval(this.evictionTimer);
|
|
1323
|
+
this.evictionTimer = setInterval(() => {
|
|
1324
|
+
const now = Date.now();
|
|
1325
|
+
for (const [signerId, entry] of this.signerEntries) {
|
|
1326
|
+
if (now - entry.lastUsedMs > this.signerIdleTtlMs) {
|
|
1327
|
+
if (entry.refreshTimer) clearTimeout(entry.refreshTimer);
|
|
1328
|
+
this.signerEntries.delete(signerId);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}, 6e4);
|
|
1253
1332
|
}
|
|
1254
1333
|
/**
|
|
1255
|
-
* Schedule next refresh with jitter and backoff
|
|
1334
|
+
* Schedule next refresh with jitter and backoff for a specific signer
|
|
1256
1335
|
*/
|
|
1257
|
-
|
|
1258
|
-
if (!this.started) {
|
|
1336
|
+
scheduleRefreshForSigner(signerId, entry) {
|
|
1337
|
+
if (!this.started || !this.signerEntries.has(signerId)) {
|
|
1259
1338
|
return;
|
|
1260
1339
|
}
|
|
1340
|
+
if (entry.refreshTimer) {
|
|
1341
|
+
clearTimeout(entry.refreshTimer);
|
|
1342
|
+
entry.refreshTimer = null;
|
|
1343
|
+
}
|
|
1261
1344
|
const baseInterval = this.baseRefreshIntervalSeconds * 1e3;
|
|
1262
1345
|
const jitter = Math.random() * 2e3;
|
|
1263
|
-
const backoff =
|
|
1346
|
+
const backoff = Math.min(
|
|
1347
|
+
Math.pow(2, entry.consecutiveFailures) * 1e3,
|
|
1348
|
+
this.maxBackoffSeconds * 1e3
|
|
1349
|
+
);
|
|
1264
1350
|
const interval = baseInterval + jitter + backoff;
|
|
1265
|
-
|
|
1266
|
-
this.
|
|
1267
|
-
|
|
1268
|
-
|
|
1351
|
+
entry.refreshTimer = setTimeout(() => {
|
|
1352
|
+
if (!this.signerEntries.has(signerId)) return;
|
|
1353
|
+
entry.acquiring = true;
|
|
1354
|
+
entry.acquirePromise = this.acquireHeartbeatForSigner(signerId, entry).then(() => {
|
|
1355
|
+
this.scheduleRefreshForSigner(signerId, entry);
|
|
1269
1356
|
}).catch((error) => {
|
|
1270
|
-
|
|
1271
|
-
console.error(
|
|
1272
|
-
this.
|
|
1357
|
+
entry.consecutiveFailures++;
|
|
1358
|
+
console.error(`[HEARTBEAT] Refresh failed for signer ${signerId} (will retry):`, error.message || error);
|
|
1359
|
+
this.scheduleRefreshForSigner(signerId, entry);
|
|
1360
|
+
}).finally(() => {
|
|
1361
|
+
entry.acquiring = false;
|
|
1362
|
+
entry.acquirePromise = null;
|
|
1273
1363
|
});
|
|
1274
1364
|
}, interval);
|
|
1275
1365
|
}
|
|
1276
|
-
/**
|
|
1277
|
-
* Calculate exponential backoff (capped at maxBackoffSeconds)
|
|
1278
|
-
*/
|
|
1279
|
-
calculateBackoff() {
|
|
1280
|
-
if (this.consecutiveFailures === 0) {
|
|
1281
|
-
return 0;
|
|
1282
|
-
}
|
|
1283
|
-
const backoffSeconds = Math.min(
|
|
1284
|
-
Math.pow(2, this.consecutiveFailures) * 1e3,
|
|
1285
|
-
this.maxBackoffSeconds * 1e3
|
|
1286
|
-
);
|
|
1287
|
-
return backoffSeconds;
|
|
1288
|
-
}
|
|
1289
1366
|
/**
|
|
1290
1367
|
* Stop background heartbeat refresher
|
|
1291
1368
|
*/
|
|
@@ -1294,45 +1371,153 @@ var HeartbeatManager = class {
|
|
|
1294
1371
|
return;
|
|
1295
1372
|
}
|
|
1296
1373
|
this.started = false;
|
|
1297
|
-
if (this.
|
|
1298
|
-
|
|
1299
|
-
this.
|
|
1374
|
+
if (this.evictionTimer) {
|
|
1375
|
+
clearInterval(this.evictionTimer);
|
|
1376
|
+
this.evictionTimer = null;
|
|
1377
|
+
}
|
|
1378
|
+
for (const [signerId, entry] of this.signerEntries) {
|
|
1379
|
+
if (entry.refreshTimer) {
|
|
1380
|
+
clearTimeout(entry.refreshTimer);
|
|
1381
|
+
entry.refreshTimer = null;
|
|
1382
|
+
}
|
|
1300
1383
|
}
|
|
1384
|
+
this.signerEntries.clear();
|
|
1301
1385
|
}
|
|
1302
1386
|
/**
|
|
1303
|
-
* Get current heartbeat token if valid
|
|
1387
|
+
* Get current heartbeat token if valid for the default signer
|
|
1388
|
+
* @deprecated Use getTokenForSigner() instead.
|
|
1304
1389
|
*/
|
|
1305
1390
|
getToken() {
|
|
1306
|
-
|
|
1307
|
-
|
|
1391
|
+
const entry = this.signerEntries.get(this.defaultSignerId);
|
|
1392
|
+
if (entry && entry.token && entry.token.expiresAt > Math.floor(Date.now() / 1e3) + 2) {
|
|
1393
|
+
entry.lastUsedMs = Date.now();
|
|
1394
|
+
return entry.token.token;
|
|
1308
1395
|
}
|
|
1309
|
-
|
|
1310
|
-
if (this.currentToken.expiresAt <= now + 2) {
|
|
1311
|
-
return null;
|
|
1312
|
-
}
|
|
1313
|
-
return this.currentToken.token;
|
|
1396
|
+
return null;
|
|
1314
1397
|
}
|
|
1315
1398
|
/**
|
|
1316
|
-
* Check if current heartbeat token is valid
|
|
1399
|
+
* Check if current heartbeat token is valid for the default signer
|
|
1400
|
+
* @deprecated Use getTokenForSigner() instead.
|
|
1317
1401
|
*/
|
|
1318
1402
|
isValid() {
|
|
1319
1403
|
return this.getToken() !== null;
|
|
1320
1404
|
}
|
|
1321
1405
|
/**
|
|
1322
|
-
* Update signer ID (called when signer is known)
|
|
1406
|
+
* Update signer ID (called when signer is known).
|
|
1407
|
+
* @deprecated Use getTokenForSigner() — signerId changes are handled automatically by the per-signer cache.
|
|
1323
1408
|
*/
|
|
1324
1409
|
updateSignerId(signerId) {
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1410
|
+
this.defaultSignerId = signerId;
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Get a valid heartbeat token for a specific signer.
|
|
1414
|
+
* Returns immediately if a cached valid token exists.
|
|
1415
|
+
* If no token, triggers acquisition and returns a Promise that resolves
|
|
1416
|
+
* when the token is available (or rejects after maxWaitMs).
|
|
1417
|
+
*/
|
|
1418
|
+
async getTokenForSigner(signerId, maxWaitMs = 2e3) {
|
|
1419
|
+
if (!this.started) {
|
|
1420
|
+
throw new GateError("HEARTBEAT_MISSING" /* HEARTBEAT_MISSING */, "HeartbeatManager not started");
|
|
1328
1421
|
}
|
|
1422
|
+
const startTime = Date.now();
|
|
1423
|
+
let entry = this.signerEntries.get(signerId);
|
|
1424
|
+
const now = Date.now();
|
|
1425
|
+
const getValidToken = (e) => {
|
|
1426
|
+
if (e.token && e.token.expiresAt > Math.floor(Date.now() / 1e3) + 2) {
|
|
1427
|
+
return e.token.token;
|
|
1428
|
+
}
|
|
1429
|
+
return null;
|
|
1430
|
+
};
|
|
1431
|
+
if (entry) {
|
|
1432
|
+
entry.lastUsedMs = now;
|
|
1433
|
+
const t2 = getValidToken(entry);
|
|
1434
|
+
if (t2) return t2;
|
|
1435
|
+
} else {
|
|
1436
|
+
if (this.signerEntries.size >= this.maxSigners) {
|
|
1437
|
+
let oldestSignerId = null;
|
|
1438
|
+
let oldestUsedMs = Infinity;
|
|
1439
|
+
for (const [sId, e] of this.signerEntries) {
|
|
1440
|
+
if (e.lastUsedMs < oldestUsedMs) {
|
|
1441
|
+
oldestUsedMs = e.lastUsedMs;
|
|
1442
|
+
oldestSignerId = sId;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
if (oldestSignerId) {
|
|
1446
|
+
const oldestEntry = this.signerEntries.get(oldestSignerId);
|
|
1447
|
+
if (oldestEntry?.refreshTimer) clearTimeout(oldestEntry.refreshTimer);
|
|
1448
|
+
this.signerEntries.delete(oldestSignerId);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
entry = {
|
|
1452
|
+
token: null,
|
|
1453
|
+
refreshTimer: null,
|
|
1454
|
+
consecutiveFailures: 0,
|
|
1455
|
+
lastAcquireAttemptMs: 0,
|
|
1456
|
+
lastUsedMs: now,
|
|
1457
|
+
acquiring: false,
|
|
1458
|
+
acquirePromise: null
|
|
1459
|
+
};
|
|
1460
|
+
this.signerEntries.set(signerId, entry);
|
|
1461
|
+
}
|
|
1462
|
+
if (entry.acquiring && entry.acquirePromise) {
|
|
1463
|
+
const remainingWait = Math.max(0, maxWaitMs - (Date.now() - startTime));
|
|
1464
|
+
try {
|
|
1465
|
+
await Promise.race([
|
|
1466
|
+
entry.acquirePromise,
|
|
1467
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), remainingWait))
|
|
1468
|
+
]);
|
|
1469
|
+
} catch (e) {
|
|
1470
|
+
}
|
|
1471
|
+
const t2 = getValidToken(entry);
|
|
1472
|
+
if (t2) return t2;
|
|
1473
|
+
}
|
|
1474
|
+
const timeSinceLastAttempt = Date.now() - entry.lastAcquireAttemptMs;
|
|
1475
|
+
let timeToWaitBeforeFetch = 0;
|
|
1476
|
+
if (timeSinceLastAttempt < this.localRateLimitMs) {
|
|
1477
|
+
timeToWaitBeforeFetch = this.localRateLimitMs - timeSinceLastAttempt;
|
|
1478
|
+
}
|
|
1479
|
+
const remainingWait2 = Math.max(0, maxWaitMs - (Date.now() - startTime));
|
|
1480
|
+
if (timeToWaitBeforeFetch >= remainingWait2) {
|
|
1481
|
+
throw new GateError(
|
|
1482
|
+
"HEARTBEAT_MISSING" /* HEARTBEAT_MISSING */,
|
|
1483
|
+
"Signing blocked: Heartbeat token is missing or expired. Gate must be alive and enforcing policy."
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
if (timeToWaitBeforeFetch > 0) {
|
|
1487
|
+
await new Promise((resolve) => setTimeout(resolve, timeToWaitBeforeFetch));
|
|
1488
|
+
}
|
|
1489
|
+
if (!entry.acquiring) {
|
|
1490
|
+
entry.acquiring = true;
|
|
1491
|
+
entry.acquirePromise = this.acquireHeartbeatForSigner(signerId, entry).finally(() => {
|
|
1492
|
+
if (entry) {
|
|
1493
|
+
entry.acquiring = false;
|
|
1494
|
+
entry.acquirePromise = null;
|
|
1495
|
+
}
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
const remainingWait3 = Math.max(0, maxWaitMs - (Date.now() - startTime));
|
|
1499
|
+
try {
|
|
1500
|
+
if (entry.acquirePromise) {
|
|
1501
|
+
await Promise.race([
|
|
1502
|
+
entry.acquirePromise,
|
|
1503
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), remainingWait3))
|
|
1504
|
+
]);
|
|
1505
|
+
}
|
|
1506
|
+
} catch (e) {
|
|
1507
|
+
}
|
|
1508
|
+
const t = getValidToken(entry);
|
|
1509
|
+
if (t) return t;
|
|
1510
|
+
throw new GateError(
|
|
1511
|
+
"HEARTBEAT_MISSING" /* HEARTBEAT_MISSING */,
|
|
1512
|
+
"Signing blocked: Heartbeat token is missing or expired. Gate must be alive and enforcing policy."
|
|
1513
|
+
);
|
|
1329
1514
|
}
|
|
1330
1515
|
/**
|
|
1331
|
-
* Acquire a new heartbeat token from Control Plane
|
|
1516
|
+
* Acquire a new heartbeat token from Control Plane for a specific signer
|
|
1332
1517
|
* NEVER logs token value (security)
|
|
1333
1518
|
* Requires x-gate-heartbeat-key header (apiKey) for authentication.
|
|
1334
1519
|
*/
|
|
1335
|
-
async
|
|
1520
|
+
async acquireHeartbeatForSigner(signerId, entry) {
|
|
1336
1521
|
if (!this.apiKey || this.apiKey.length === 0) {
|
|
1337
1522
|
throw new GateError(
|
|
1338
1523
|
"UNAUTHORIZED" /* UNAUTHORIZED */,
|
|
@@ -1340,6 +1525,7 @@ var HeartbeatManager = class {
|
|
|
1340
1525
|
{}
|
|
1341
1526
|
);
|
|
1342
1527
|
}
|
|
1528
|
+
entry.lastAcquireAttemptMs = Date.now();
|
|
1343
1529
|
try {
|
|
1344
1530
|
const response = await this.httpClient.request({
|
|
1345
1531
|
method: "POST",
|
|
@@ -1349,12 +1535,15 @@ var HeartbeatManager = class {
|
|
|
1349
1535
|
},
|
|
1350
1536
|
body: {
|
|
1351
1537
|
tenantId: this.tenantId,
|
|
1352
|
-
signerId
|
|
1538
|
+
signerId,
|
|
1353
1539
|
environment: this.environment,
|
|
1354
1540
|
clientInstanceId: this.clientInstanceId,
|
|
1355
1541
|
sdkVersion: this.sdkVersion
|
|
1356
1542
|
}
|
|
1357
1543
|
});
|
|
1544
|
+
if (!this.signerEntries.has(signerId)) {
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1358
1547
|
if (response.success && response.data) {
|
|
1359
1548
|
const token = response.data.heartbeatToken;
|
|
1360
1549
|
const expiresAt = response.data.expiresAt;
|
|
@@ -1364,18 +1553,23 @@ var HeartbeatManager = class {
|
|
|
1364
1553
|
"Invalid heartbeat response: missing token or expiresAt"
|
|
1365
1554
|
);
|
|
1366
1555
|
}
|
|
1367
|
-
|
|
1556
|
+
entry.token = {
|
|
1368
1557
|
token,
|
|
1369
1558
|
expiresAt,
|
|
1370
1559
|
jti: response.data.jti,
|
|
1371
1560
|
policyHash: response.data.policyHash
|
|
1372
1561
|
};
|
|
1562
|
+
entry.consecutiveFailures = 0;
|
|
1373
1563
|
console.log("[HEARTBEAT] Acquired heartbeat token", {
|
|
1374
1564
|
expiresAt,
|
|
1565
|
+
signerId,
|
|
1375
1566
|
jti: response.data.jti,
|
|
1376
1567
|
policyHash: response.data.policyHash?.substring(0, 8) + "..."
|
|
1377
1568
|
// DO NOT log token value
|
|
1378
1569
|
});
|
|
1570
|
+
if (!entry.refreshTimer) {
|
|
1571
|
+
this.scheduleRefreshForSigner(signerId, entry);
|
|
1572
|
+
}
|
|
1379
1573
|
} else {
|
|
1380
1574
|
const error = response.error || {};
|
|
1381
1575
|
throw new GateError(
|
|
@@ -1384,7 +1578,7 @@ var HeartbeatManager = class {
|
|
|
1384
1578
|
);
|
|
1385
1579
|
}
|
|
1386
1580
|
} catch (error) {
|
|
1387
|
-
console.error(
|
|
1581
|
+
console.error(`[HEARTBEAT] Failed to acquire heartbeat for signer ${signerId}:`, error.message || error);
|
|
1388
1582
|
throw error;
|
|
1389
1583
|
}
|
|
1390
1584
|
}
|
|
@@ -1641,6 +1835,7 @@ var IamPermissionRiskChecker = class {
|
|
|
1641
1835
|
};
|
|
1642
1836
|
|
|
1643
1837
|
// src/client/GateClient.ts
|
|
1838
|
+
var DEFAULT_SIGNER_ID = "gate-sdk-client";
|
|
1644
1839
|
var GateClient = class {
|
|
1645
1840
|
config;
|
|
1646
1841
|
httpClient;
|
|
@@ -1715,7 +1910,7 @@ var GateClient = class {
|
|
|
1715
1910
|
// 5s timeout for heartbeat
|
|
1716
1911
|
userAgent: config.userAgent
|
|
1717
1912
|
});
|
|
1718
|
-
const initialSignerId = config.signerId ??
|
|
1913
|
+
const initialSignerId = config.signerId ?? DEFAULT_SIGNER_ID;
|
|
1719
1914
|
this.heartbeatManager = new HeartbeatManager({
|
|
1720
1915
|
httpClient: heartbeatHttpClient,
|
|
1721
1916
|
tenantId: config.tenantId,
|
|
@@ -1790,26 +1985,10 @@ var GateClient = class {
|
|
|
1790
1985
|
const requestMode = req.mode || this.mode;
|
|
1791
1986
|
const requireToken = this.getRequireDecisionToken();
|
|
1792
1987
|
const executeRequest = async () => {
|
|
1793
|
-
if (!this.config.local && this.heartbeatManager && req.signingContext?.signerId) {
|
|
1794
|
-
this.heartbeatManager.updateSignerId(req.signingContext.signerId);
|
|
1795
|
-
}
|
|
1796
1988
|
let heartbeatToken = null;
|
|
1797
1989
|
if (!this.config.local && this.heartbeatManager) {
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
const maxWaitMs = 2e3;
|
|
1801
|
-
const startTime2 = Date.now();
|
|
1802
|
-
while (!heartbeatToken && Date.now() - startTime2 < maxWaitMs) {
|
|
1803
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1804
|
-
heartbeatToken = this.heartbeatManager.getToken();
|
|
1805
|
-
}
|
|
1806
|
-
}
|
|
1807
|
-
if (!heartbeatToken) {
|
|
1808
|
-
throw new GateError(
|
|
1809
|
-
"HEARTBEAT_MISSING" /* HEARTBEAT_MISSING */,
|
|
1810
|
-
"Signing blocked: Heartbeat token is missing or expired. Gate must be alive and enforcing policy."
|
|
1811
|
-
);
|
|
1812
|
-
}
|
|
1990
|
+
const effectiveSignerId2 = req.signingContext?.signerId ?? req.signingContext?.actorPrincipal ?? DEFAULT_SIGNER_ID;
|
|
1991
|
+
heartbeatToken = await this.heartbeatManager.getTokenForSigner(effectiveSignerId2, 2e3);
|
|
1813
1992
|
}
|
|
1814
1993
|
const txIntent = { ...req.txIntent };
|
|
1815
1994
|
if (txIntent.to && !txIntent.toAddress) {
|
|
@@ -1822,10 +2001,11 @@ var GateClient = class {
|
|
|
1822
2001
|
if (txIntent.from && !txIntent.fromAddress) {
|
|
1823
2002
|
delete txIntent.from;
|
|
1824
2003
|
}
|
|
2004
|
+
const effectiveSignerId = req.signingContext?.signerId ?? req.signingContext?.actorPrincipal ?? DEFAULT_SIGNER_ID;
|
|
1825
2005
|
const signingContext = {
|
|
1826
2006
|
...req.signingContext,
|
|
1827
|
-
actorPrincipal: req.signingContext?.actorPrincipal ?? req.signingContext?.signerId ??
|
|
1828
|
-
signerId:
|
|
2007
|
+
actorPrincipal: req.signingContext?.actorPrincipal ?? req.signingContext?.signerId ?? DEFAULT_SIGNER_ID,
|
|
2008
|
+
signerId: effectiveSignerId
|
|
1829
2009
|
};
|
|
1830
2010
|
if (heartbeatToken) {
|
|
1831
2011
|
signingContext.heartbeatToken = heartbeatToken;
|
|
@@ -1942,6 +2122,9 @@ var GateClient = class {
|
|
|
1942
2122
|
enforced: responseData.enforced ?? requestMode === "ENFORCE",
|
|
1943
2123
|
shadowWouldBlock: responseData.shadow_would_block ?? responseData.shadowWouldBlock ?? false,
|
|
1944
2124
|
mode: responseData.mode ?? requestMode,
|
|
2125
|
+
receipt: responseData.receipt,
|
|
2126
|
+
decisionHash: responseData.decision_hash ?? responseData.decisionHash,
|
|
2127
|
+
receiptSignature: responseData.receipt_signature ?? responseData.receiptSignature,
|
|
1945
2128
|
...simulationData ? {
|
|
1946
2129
|
simulation: {
|
|
1947
2130
|
willRevert: simulationData.willRevert ?? simulationData.will_revert ?? false,
|
|
@@ -3035,6 +3218,7 @@ exports.buildTxBindingObject = buildTxBindingObject;
|
|
|
3035
3218
|
exports.computeTxDigest = computeTxDigest;
|
|
3036
3219
|
exports.createGateClient = createGateClient;
|
|
3037
3220
|
exports.default = GateClient;
|
|
3221
|
+
exports.noOpMetricsSink = noOpMetricsSink;
|
|
3038
3222
|
exports.wrapKmsClient = wrapKmsClient;
|
|
3039
3223
|
//# sourceMappingURL=index.cjs.map
|
|
3040
3224
|
//# sourceMappingURL=index.cjs.map
|