sentinelayer-cli 0.1.2 → 0.4.4
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 +998 -996
- package/bin/create-sentinelayer.js +5 -5
- package/bin/sentinelayer-cli.js +4 -4
- package/bin/sl.js +5 -5
- package/package.json +63 -54
- package/src/agents/jules/config/definition.js +209 -209
- package/src/agents/jules/config/system-prompt.js +175 -175
- package/src/agents/jules/error-intake.js +51 -51
- package/src/agents/jules/fix-cycle.js +377 -377
- package/src/agents/jules/loop.js +367 -367
- package/src/agents/jules/pulse.js +327 -319
- package/src/agents/jules/stream.js +186 -186
- package/src/agents/jules/swarm/file-scanner.js +74 -74
- package/src/agents/jules/swarm/index.js +11 -11
- package/src/agents/jules/swarm/orchestrator.js +362 -362
- package/src/agents/jules/swarm/pattern-hunter.js +123 -123
- package/src/agents/jules/swarm/sub-agent.js +308 -308
- package/src/agents/jules/tools/auth-audit.js +557 -222
- package/src/agents/jules/tools/dispatch.js +327 -327
- package/src/agents/jules/tools/file-edit.js +180 -180
- package/src/agents/jules/tools/file-read.js +100 -100
- package/src/agents/jules/tools/frontend-analyze.js +570 -570
- package/src/agents/jules/tools/glob.js +168 -168
- package/src/agents/jules/tools/grep.js +228 -228
- package/src/agents/jules/tools/index.js +29 -29
- package/src/agents/jules/tools/path-guards.js +161 -161
- package/src/agents/jules/tools/runtime-audit.js +503 -493
- package/src/agents/jules/tools/shell.js +383 -383
- package/src/agents/jules/tools/url-policy.js +100 -0
- package/src/ai/aidenid.js +972 -945
- package/src/ai/client.js +508 -508
- package/src/ai/domain-target-store.js +268 -268
- package/src/ai/identity-store.js +270 -270
- package/src/ai/site-store.js +145 -145
- package/src/audit/agents/architecture.js +180 -180
- package/src/audit/agents/compliance.js +179 -179
- package/src/audit/agents/documentation.js +165 -165
- package/src/audit/agents/performance.js +145 -145
- package/src/audit/agents/security.js +215 -215
- package/src/audit/agents/testing.js +172 -172
- package/src/audit/orchestrator.js +557 -557
- package/src/audit/package.js +204 -204
- package/src/audit/registry.js +284 -284
- package/src/audit/replay.js +103 -103
- package/src/auth/gate.js +45 -11
- package/src/auth/http.js +270 -113
- package/src/auth/service.js +891 -848
- package/src/auth/session-store.js +359 -345
- package/src/cli.js +252 -252
- package/src/commands/ai/identity-lifecycle.js +1338 -1337
- package/src/commands/ai/provision-governance.js +1272 -1246
- package/src/commands/ai/shared.js +147 -147
- package/src/commands/ai.js +11 -11
- package/src/commands/apply.js +12 -12
- package/src/commands/audit.js +1166 -1166
- package/src/commands/auth.js +375 -366
- package/src/commands/chat.js +191 -191
- package/src/commands/config.js +184 -184
- package/src/commands/cost.js +311 -311
- package/src/commands/daemon/core.js +850 -850
- package/src/commands/daemon/extended.js +1048 -1048
- package/src/commands/daemon/shared.js +213 -213
- package/src/commands/daemon.js +11 -11
- package/src/commands/guide.js +174 -174
- package/src/commands/ingest.js +58 -58
- package/src/commands/init.js +55 -55
- package/src/commands/legacy-args.js +10 -10
- package/src/commands/mcp.js +461 -404
- package/src/commands/omargate.js +15 -15
- package/src/commands/persona.js +20 -20
- package/src/commands/plugin.js +260 -260
- package/src/commands/policy.js +132 -132
- package/src/commands/prompt.js +238 -238
- package/src/commands/review.js +704 -704
- package/src/commands/scan.js +866 -788
- package/src/commands/spec.js +716 -716
- package/src/commands/swarm.js +651 -651
- package/src/commands/telemetry.js +202 -202
- package/src/commands/watch.js +510 -510
- package/src/config/agent-dictionary.js +182 -182
- package/src/config/io.js +56 -56
- package/src/config/paths.js +18 -18
- package/src/config/schema.js +55 -55
- package/src/config/service.js +184 -184
- package/src/cost/budget.js +235 -235
- package/src/cost/history.js +188 -188
- package/src/cost/tracker.js +171 -171
- package/src/daemon/artifact-lineage.js +534 -534
- package/src/daemon/assignment-ledger.js +770 -770
- package/src/daemon/ast-parser-layer.js +258 -258
- package/src/daemon/budget-governor.js +633 -633
- package/src/daemon/callgraph-overlay.js +646 -646
- package/src/daemon/error-worker.js +626 -626
- package/src/daemon/hybrid-mapper.js +929 -929
- package/src/daemon/jira-lifecycle.js +632 -632
- package/src/daemon/operator-control.js +657 -657
- package/src/daemon/reliability-lane.js +471 -471
- package/src/daemon/watchdog.js +971 -971
- package/src/guide/generator.js +316 -316
- package/src/ingest/engine.js +918 -918
- package/src/legacy-cli.js +2592 -2435
- package/src/mcp/registry.js +695 -695
- package/src/memory/blackboard.js +301 -301
- package/src/memory/retrieval.js +581 -581
- package/src/plugin/manifest.js +553 -553
- package/src/policy/packs.js +144 -144
- package/src/prompt/generator.js +118 -106
- package/src/review/ai-review.js +669 -669
- package/src/review/local-review.js +1295 -1284
- package/src/review/replay.js +235 -235
- package/src/review/report.js +664 -664
- package/src/review/spec-binding.js +487 -487
- package/src/scaffold/generator.js +67 -0
- package/src/scaffold/templates.js +150 -0
- package/src/scan/generator.js +418 -351
- package/src/scan/gh-secrets.js +107 -0
- package/src/spec/generator.js +519 -519
- package/src/spec/regenerate.js +237 -237
- package/src/spec/templates.js +91 -91
- package/src/swarm/dashboard.js +247 -247
- package/src/swarm/factory.js +363 -363
- package/src/swarm/pentest.js +934 -934
- package/src/swarm/registry.js +419 -419
- package/src/swarm/report.js +158 -158
- package/src/swarm/runtime.js +576 -576
- package/src/swarm/scenario-dsl.js +272 -272
- package/src/telemetry/ledger.js +302 -302
- package/src/telemetry/sync.js +107 -61
- package/src/ui/markdown.js +220 -220
package/src/audit/replay.js
CHANGED
|
@@ -1,103 +1,103 @@
|
|
|
1
|
-
import fsp from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
function normalizeString(value) {
|
|
5
|
-
return String(value || "").trim();
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
function toPosixPath(value) {
|
|
9
|
-
return String(value || "").replace(/\\/g, "/");
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function fingerprintFinding(finding = {}) {
|
|
13
|
-
return [
|
|
14
|
-
normalizeString(finding.severity).toUpperCase(),
|
|
15
|
-
toPosixPath(finding.file),
|
|
16
|
-
String(Number(finding.line || 0)),
|
|
17
|
-
normalizeString(finding.message).toLowerCase(),
|
|
18
|
-
].join("|");
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function collectFindings(report = {}) {
|
|
22
|
-
const flattened = [];
|
|
23
|
-
for (const agent of report.agentResults || []) {
|
|
24
|
-
for (const finding of agent.findings || []) {
|
|
25
|
-
flattened.push({
|
|
26
|
-
severity: normalizeString(finding.severity).toUpperCase(),
|
|
27
|
-
file: toPosixPath(finding.file),
|
|
28
|
-
line: Number(finding.line || 0),
|
|
29
|
-
message: normalizeString(finding.message),
|
|
30
|
-
ruleId: normalizeString(finding.ruleId),
|
|
31
|
-
ownerAgentId: agent.agentId,
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return flattened;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function summaryDelta(base = {}, candidate = {}) {
|
|
39
|
-
const keys = ["P0", "P1", "P2", "P3"];
|
|
40
|
-
const delta = {};
|
|
41
|
-
for (const key of keys) {
|
|
42
|
-
delta[key] = Number(candidate[key] || 0) - Number(base[key] || 0);
|
|
43
|
-
}
|
|
44
|
-
delta.blockingChanged = Boolean(base.blocking) !== Boolean(candidate.blocking);
|
|
45
|
-
return delta;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function compareAuditReports(baseReport = {}, candidateReport = {}) {
|
|
49
|
-
const baseFindings = collectFindings(baseReport);
|
|
50
|
-
const candidateFindings = collectFindings(candidateReport);
|
|
51
|
-
const baseByFingerprint = new Map(baseFindings.map((finding) => [fingerprintFinding(finding), finding]));
|
|
52
|
-
const candidateByFingerprint = new Map(
|
|
53
|
-
candidateFindings.map((finding) => [fingerprintFinding(finding), finding])
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
const added = [];
|
|
57
|
-
const removed = [];
|
|
58
|
-
|
|
59
|
-
for (const [fingerprint, finding] of candidateByFingerprint.entries()) {
|
|
60
|
-
if (!baseByFingerprint.has(fingerprint)) {
|
|
61
|
-
added.push(finding);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
for (const [fingerprint, finding] of baseByFingerprint.entries()) {
|
|
65
|
-
if (!candidateByFingerprint.has(fingerprint)) {
|
|
66
|
-
removed.push(finding);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const deterministicEquivalent = added.length === 0 && removed.length === 0;
|
|
71
|
-
return {
|
|
72
|
-
schemaVersion: "1.0.0",
|
|
73
|
-
generatedAt: new Date().toISOString(),
|
|
74
|
-
baseRunId: normalizeString(baseReport.runId),
|
|
75
|
-
candidateRunId: normalizeString(candidateReport.runId),
|
|
76
|
-
baseSummary: baseReport.summary || { P0: 0, P1: 0, P2: 0, P3: 0, blocking: false },
|
|
77
|
-
candidateSummary: candidateReport.summary || { P0: 0, P1: 0, P2: 0, P3: 0, blocking: false },
|
|
78
|
-
summaryDelta: summaryDelta(baseReport.summary || {}, candidateReport.summary || {}),
|
|
79
|
-
baseFindingCount: baseFindings.length,
|
|
80
|
-
candidateFindingCount: candidateFindings.length,
|
|
81
|
-
addedCount: added.length,
|
|
82
|
-
removedCount: removed.length,
|
|
83
|
-
deterministicEquivalent,
|
|
84
|
-
added: added.slice(0, 500),
|
|
85
|
-
removed: removed.slice(0, 500),
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export async function writeAuditComparisonArtifact({
|
|
90
|
-
baseReport = {},
|
|
91
|
-
candidateReport = {},
|
|
92
|
-
outputDirectory = "",
|
|
93
|
-
} = {}) {
|
|
94
|
-
const resolvedOutputDirectory = path.resolve(String(outputDirectory || "."));
|
|
95
|
-
const comparison = compareAuditReports(baseReport, candidateReport);
|
|
96
|
-
const fileName = `AUDIT_COMPARISON_${comparison.baseRunId}_vs_${comparison.candidateRunId}.json`;
|
|
97
|
-
const outputPath = path.join(resolvedOutputDirectory, fileName);
|
|
98
|
-
await fsp.writeFile(outputPath, `${JSON.stringify(comparison, null, 2)}\n`, "utf-8");
|
|
99
|
-
return {
|
|
100
|
-
comparison,
|
|
101
|
-
outputPath,
|
|
102
|
-
};
|
|
103
|
-
}
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
function normalizeString(value) {
|
|
5
|
+
return String(value || "").trim();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function toPosixPath(value) {
|
|
9
|
+
return String(value || "").replace(/\\/g, "/");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function fingerprintFinding(finding = {}) {
|
|
13
|
+
return [
|
|
14
|
+
normalizeString(finding.severity).toUpperCase(),
|
|
15
|
+
toPosixPath(finding.file),
|
|
16
|
+
String(Number(finding.line || 0)),
|
|
17
|
+
normalizeString(finding.message).toLowerCase(),
|
|
18
|
+
].join("|");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function collectFindings(report = {}) {
|
|
22
|
+
const flattened = [];
|
|
23
|
+
for (const agent of report.agentResults || []) {
|
|
24
|
+
for (const finding of agent.findings || []) {
|
|
25
|
+
flattened.push({
|
|
26
|
+
severity: normalizeString(finding.severity).toUpperCase(),
|
|
27
|
+
file: toPosixPath(finding.file),
|
|
28
|
+
line: Number(finding.line || 0),
|
|
29
|
+
message: normalizeString(finding.message),
|
|
30
|
+
ruleId: normalizeString(finding.ruleId),
|
|
31
|
+
ownerAgentId: agent.agentId,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return flattened;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function summaryDelta(base = {}, candidate = {}) {
|
|
39
|
+
const keys = ["P0", "P1", "P2", "P3"];
|
|
40
|
+
const delta = {};
|
|
41
|
+
for (const key of keys) {
|
|
42
|
+
delta[key] = Number(candidate[key] || 0) - Number(base[key] || 0);
|
|
43
|
+
}
|
|
44
|
+
delta.blockingChanged = Boolean(base.blocking) !== Boolean(candidate.blocking);
|
|
45
|
+
return delta;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function compareAuditReports(baseReport = {}, candidateReport = {}) {
|
|
49
|
+
const baseFindings = collectFindings(baseReport);
|
|
50
|
+
const candidateFindings = collectFindings(candidateReport);
|
|
51
|
+
const baseByFingerprint = new Map(baseFindings.map((finding) => [fingerprintFinding(finding), finding]));
|
|
52
|
+
const candidateByFingerprint = new Map(
|
|
53
|
+
candidateFindings.map((finding) => [fingerprintFinding(finding), finding])
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const added = [];
|
|
57
|
+
const removed = [];
|
|
58
|
+
|
|
59
|
+
for (const [fingerprint, finding] of candidateByFingerprint.entries()) {
|
|
60
|
+
if (!baseByFingerprint.has(fingerprint)) {
|
|
61
|
+
added.push(finding);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const [fingerprint, finding] of baseByFingerprint.entries()) {
|
|
65
|
+
if (!candidateByFingerprint.has(fingerprint)) {
|
|
66
|
+
removed.push(finding);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const deterministicEquivalent = added.length === 0 && removed.length === 0;
|
|
71
|
+
return {
|
|
72
|
+
schemaVersion: "1.0.0",
|
|
73
|
+
generatedAt: new Date().toISOString(),
|
|
74
|
+
baseRunId: normalizeString(baseReport.runId),
|
|
75
|
+
candidateRunId: normalizeString(candidateReport.runId),
|
|
76
|
+
baseSummary: baseReport.summary || { P0: 0, P1: 0, P2: 0, P3: 0, blocking: false },
|
|
77
|
+
candidateSummary: candidateReport.summary || { P0: 0, P1: 0, P2: 0, P3: 0, blocking: false },
|
|
78
|
+
summaryDelta: summaryDelta(baseReport.summary || {}, candidateReport.summary || {}),
|
|
79
|
+
baseFindingCount: baseFindings.length,
|
|
80
|
+
candidateFindingCount: candidateFindings.length,
|
|
81
|
+
addedCount: added.length,
|
|
82
|
+
removedCount: removed.length,
|
|
83
|
+
deterministicEquivalent,
|
|
84
|
+
added: added.slice(0, 500),
|
|
85
|
+
removed: removed.slice(0, 500),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function writeAuditComparisonArtifact({
|
|
90
|
+
baseReport = {},
|
|
91
|
+
candidateReport = {},
|
|
92
|
+
outputDirectory = "",
|
|
93
|
+
} = {}) {
|
|
94
|
+
const resolvedOutputDirectory = path.resolve(String(outputDirectory || "."));
|
|
95
|
+
const comparison = compareAuditReports(baseReport, candidateReport);
|
|
96
|
+
const fileName = `AUDIT_COMPARISON_${comparison.baseRunId}_vs_${comparison.candidateRunId}.json`;
|
|
97
|
+
const outputPath = path.join(resolvedOutputDirectory, fileName);
|
|
98
|
+
await fsp.writeFile(outputPath, `${JSON.stringify(comparison, null, 2)}\n`, "utf-8");
|
|
99
|
+
return {
|
|
100
|
+
comparison,
|
|
101
|
+
outputPath,
|
|
102
|
+
};
|
|
103
|
+
}
|
package/src/auth/gate.js
CHANGED
|
@@ -16,6 +16,7 @@ import { readStoredSession } from "./session-store.js";
|
|
|
16
16
|
|
|
17
17
|
const AUTH_BYPASS_COMMANDS = new Set([
|
|
18
18
|
"auth", // auth subcommands handle their own auth
|
|
19
|
+
"help", // help must work without login so agents can discover commands
|
|
19
20
|
"--help",
|
|
20
21
|
"-h",
|
|
21
22
|
"--version",
|
|
@@ -27,6 +28,46 @@ const NO_AUTH_REQUIRED = new Set([
|
|
|
27
28
|
"config", // local config inspection
|
|
28
29
|
]);
|
|
29
30
|
|
|
31
|
+
function hasTrustedBypassContext() {
|
|
32
|
+
const nonce = String(process.env.SENTINELAYER_CLI_TEST_BYPASS_NONCE || "").trim();
|
|
33
|
+
return (
|
|
34
|
+
process.env.NODE_ENV === "test" &&
|
|
35
|
+
process.env.SENTINELAYER_CLI_TEST_MODE === "1" &&
|
|
36
|
+
nonce.length >= 12
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isValidSessionToken(session) {
|
|
41
|
+
const token = String(session?.token || "");
|
|
42
|
+
if (!token || token !== token.trim()) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (/\s/.test(token)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
// Require printable ASCII only for bearer token material in local metadata.
|
|
49
|
+
if (/[^\x21-\x7E]/.test(token)) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const tokenPrefix = String(session?.tokenPrefix || "").trim();
|
|
53
|
+
if (tokenPrefix && !token.startsWith(tokenPrefix)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isSessionUnexpired(tokenExpiresAt) {
|
|
60
|
+
const normalized = String(tokenExpiresAt || "").trim();
|
|
61
|
+
if (!normalized) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
const expiresAt = new Date(normalized).getTime();
|
|
65
|
+
if (!Number.isFinite(expiresAt)) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return expiresAt >= Date.now();
|
|
69
|
+
}
|
|
70
|
+
|
|
30
71
|
/**
|
|
31
72
|
* Check if the current command requires authentication.
|
|
32
73
|
* Returns true if auth is required but user is not logged in.
|
|
@@ -46,22 +87,15 @@ export async function checkAuthGate(args) {
|
|
|
46
87
|
return { authenticated: true, session: null, bypassReason: "no_auth_required" };
|
|
47
88
|
}
|
|
48
89
|
|
|
49
|
-
//
|
|
50
|
-
if (process.env.SENTINELAYER_CLI_SKIP_AUTH === "1"
|
|
51
|
-
return { authenticated: true, session: null, bypassReason: "
|
|
90
|
+
// Explicit bypass is gated to trusted test contexts only.
|
|
91
|
+
if (process.env.SENTINELAYER_CLI_SKIP_AUTH === "1" && hasTrustedBypassContext()) {
|
|
92
|
+
return { authenticated: true, session: null, bypassReason: "env_bypass_guarded" };
|
|
52
93
|
}
|
|
53
94
|
|
|
54
95
|
// Check for stored session
|
|
55
96
|
try {
|
|
56
97
|
const session = await readStoredSession();
|
|
57
|
-
if (session && session.
|
|
58
|
-
// Check if token is expired
|
|
59
|
-
if (session.tokenExpiresAt) {
|
|
60
|
-
const expiresAt = new Date(session.tokenExpiresAt).getTime();
|
|
61
|
-
if (expiresAt < Date.now()) {
|
|
62
|
-
return { authenticated: false, session: null, bypassReason: null };
|
|
63
|
-
}
|
|
64
|
-
}
|
|
98
|
+
if (session && isValidSessionToken(session) && isSessionUnexpired(session.tokenExpiresAt)) {
|
|
65
99
|
return { authenticated: true, session, bypassReason: null };
|
|
66
100
|
}
|
|
67
101
|
} catch {
|
package/src/auth/http.js
CHANGED
|
@@ -1,113 +1,270 @@
|
|
|
1
|
-
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Default timeout applied to Sentinelayer API requests when no override is provided.
|
|
5
|
-
* @type {number}
|
|
6
|
-
*/
|
|
7
|
-
export const DEFAULT_REQUEST_TIMEOUT_MS = 20_000;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default timeout applied to Sentinelayer API requests when no override is provided.
|
|
5
|
+
* @type {number}
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 20_000;
|
|
8
|
+
export const DEFAULT_MAX_RETRIES = 2;
|
|
9
|
+
export const DEFAULT_RETRY_DELAY_MS = 250;
|
|
10
|
+
export const MAX_RETRY_DELAY_MS = 2_000;
|
|
11
|
+
export const CIRCUIT_BREAKER_THRESHOLD = 5;
|
|
12
|
+
export const CIRCUIT_BREAKER_COOLDOWN_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
15
|
+
const CIRCUIT_TRACK_STATUS_CODES = new Set([401, 403, 408, 425, 429, 500, 502, 503, 504]);
|
|
16
|
+
const circuitState = {
|
|
17
|
+
consecutiveFailures: 0,
|
|
18
|
+
openedAtMs: 0,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function normalizeApiError(errorPayload = {}) {
|
|
22
|
+
if (!errorPayload || typeof errorPayload !== "object" || Array.isArray(errorPayload)) {
|
|
23
|
+
return {
|
|
24
|
+
code: "UNKNOWN",
|
|
25
|
+
message: "Unknown API error",
|
|
26
|
+
requestId: null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
code: String(errorPayload.code || "UNKNOWN"),
|
|
31
|
+
message: String(errorPayload.message || "Unknown API error"),
|
|
32
|
+
requestId: errorPayload.request_id ? String(errorPayload.request_id) : null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class SentinelayerApiError extends Error {
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} message
|
|
39
|
+
* @param {{ status?: number, code?: string, requestId?: string | null }} [options]
|
|
40
|
+
*/
|
|
41
|
+
constructor(message, { status = 500, code = "UNKNOWN", requestId = null } = {}) {
|
|
42
|
+
super(String(message || "Sentinelayer API error"));
|
|
43
|
+
this.name = "SentinelayerApiError";
|
|
44
|
+
this.status = Number(status || 500);
|
|
45
|
+
this.code = String(code || "UNKNOWN");
|
|
46
|
+
this.requestId = requestId ? String(requestId) : null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizePositiveNumber(value, fallback) {
|
|
51
|
+
const parsed = Number(value);
|
|
52
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeNonNegativeInteger(value, fallback) {
|
|
59
|
+
const parsed = Number(value);
|
|
60
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
61
|
+
return fallback;
|
|
62
|
+
}
|
|
63
|
+
return Math.floor(parsed);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseRetryAfterMs(value) {
|
|
67
|
+
const raw = String(value || "").trim();
|
|
68
|
+
if (!raw) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const seconds = Number(raw);
|
|
72
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
73
|
+
return Math.round(seconds * 1000);
|
|
74
|
+
}
|
|
75
|
+
const parsedDate = Date.parse(raw);
|
|
76
|
+
if (Number.isFinite(parsedDate)) {
|
|
77
|
+
const delta = parsedDate - Date.now();
|
|
78
|
+
if (delta > 0) {
|
|
79
|
+
return delta;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function computeBackoffMs({ attempt, retryDelayMs, retryAfterHeader }) {
|
|
86
|
+
const retryAfterMs = parseRetryAfterMs(retryAfterHeader);
|
|
87
|
+
if (retryAfterMs !== null) {
|
|
88
|
+
return Math.min(retryAfterMs, MAX_RETRY_DELAY_MS);
|
|
89
|
+
}
|
|
90
|
+
const exponent = Math.max(0, Number(attempt) - 1);
|
|
91
|
+
const computed = Math.round(retryDelayMs * Math.pow(2, exponent));
|
|
92
|
+
return Math.min(Math.max(1, computed), MAX_RETRY_DELAY_MS);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isCircuitOpen() {
|
|
96
|
+
if (circuitState.openedAtMs <= 0) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (Date.now() - circuitState.openedAtMs >= CIRCUIT_BREAKER_COOLDOWN_MS) {
|
|
100
|
+
circuitState.openedAtMs = 0;
|
|
101
|
+
circuitState.consecutiveFailures = 0;
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function recordFailureForCircuit() {
|
|
108
|
+
circuitState.consecutiveFailures += 1;
|
|
109
|
+
if (circuitState.consecutiveFailures >= CIRCUIT_BREAKER_THRESHOLD) {
|
|
110
|
+
circuitState.openedAtMs = Date.now();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function recordSuccessForCircuit() {
|
|
115
|
+
circuitState.consecutiveFailures = 0;
|
|
116
|
+
circuitState.openedAtMs = 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function shouldRetryStatus(statusCode) {
|
|
120
|
+
return RETRYABLE_STATUS_CODES.has(Number(statusCode || 0));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function shouldRecordFailureForStatus(statusCode) {
|
|
124
|
+
return CIRCUIT_TRACK_STATUS_CODES.has(Number(statusCode || 0));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function __resetRequestCircuitForTests() {
|
|
128
|
+
circuitState.consecutiveFailures = 0;
|
|
129
|
+
circuitState.openedAtMs = 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Execute an HTTP request against the Sentinelayer API and parse a JSON response.
|
|
134
|
+
* Throws `SentinelayerApiError` for transport errors, timeouts, API failures, and invalid JSON.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} url
|
|
137
|
+
* @param {{
|
|
138
|
+
* method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
|
|
139
|
+
* headers?: Record<string, string>,
|
|
140
|
+
* body?: unknown,
|
|
141
|
+
* timeoutMs?: number
|
|
142
|
+
* maxRetries?: number,
|
|
143
|
+
* retryDelayMs?: number
|
|
144
|
+
* }} [options]
|
|
145
|
+
* @returns {Promise<any>}
|
|
146
|
+
*/
|
|
147
|
+
export async function requestJson(
|
|
148
|
+
url,
|
|
149
|
+
{
|
|
150
|
+
method = "GET",
|
|
151
|
+
headers = {},
|
|
152
|
+
body,
|
|
153
|
+
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
154
|
+
maxRetries = DEFAULT_MAX_RETRIES,
|
|
155
|
+
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
|
|
156
|
+
} = {}
|
|
157
|
+
) {
|
|
158
|
+
if (isCircuitOpen()) {
|
|
159
|
+
throw new SentinelayerApiError("Request circuit breaker is open after consecutive API failures.", {
|
|
160
|
+
status: 503,
|
|
161
|
+
code: "CIRCUIT_OPEN",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const normalizedTimeoutMs = normalizePositiveNumber(timeoutMs, DEFAULT_REQUEST_TIMEOUT_MS);
|
|
166
|
+
const normalizedMaxRetries = normalizeNonNegativeInteger(maxRetries, DEFAULT_MAX_RETRIES);
|
|
167
|
+
const normalizedRetryDelayMs = normalizePositiveNumber(retryDelayMs, DEFAULT_RETRY_DELAY_MS);
|
|
168
|
+
|
|
169
|
+
let lastRetryableError = null;
|
|
170
|
+
for (let attempt = 0; attempt <= normalizedMaxRetries; attempt += 1) {
|
|
171
|
+
const controller = new AbortController();
|
|
172
|
+
const timeout = setTimeout(() => controller.abort(), normalizedTimeoutMs);
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const response = await fetch(String(url), {
|
|
176
|
+
method,
|
|
177
|
+
headers: {
|
|
178
|
+
"Content-Type": "application/json",
|
|
179
|
+
...headers,
|
|
180
|
+
},
|
|
181
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
182
|
+
signal: controller.signal,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const rawBody = await response.text();
|
|
186
|
+
let json = {};
|
|
187
|
+
if (rawBody.trim()) {
|
|
188
|
+
try {
|
|
189
|
+
json = JSON.parse(rawBody);
|
|
190
|
+
} catch {
|
|
191
|
+
if (response.ok) {
|
|
192
|
+
throw new SentinelayerApiError("Invalid JSON returned by API.", {
|
|
193
|
+
status: response.status,
|
|
194
|
+
code: "INVALID_JSON",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (response.ok) {
|
|
201
|
+
recordSuccessForCircuit();
|
|
202
|
+
return json;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const apiError = normalizeApiError(json && typeof json === "object" ? json.error : {});
|
|
206
|
+
const statusCode = Number(response.status || 500);
|
|
207
|
+
const retryable = shouldRetryStatus(statusCode);
|
|
208
|
+
const shouldRecordCircuitFailure = shouldRecordFailureForStatus(statusCode);
|
|
209
|
+
const error = new SentinelayerApiError(apiError.message, {
|
|
210
|
+
status: statusCode,
|
|
211
|
+
code: apiError.code,
|
|
212
|
+
requestId: apiError.requestId,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (!retryable || attempt >= normalizedMaxRetries) {
|
|
216
|
+
if (shouldRecordCircuitFailure) {
|
|
217
|
+
recordFailureForCircuit();
|
|
218
|
+
}
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
lastRetryableError = error;
|
|
223
|
+
const delayMs = computeBackoffMs({
|
|
224
|
+
attempt: attempt + 1,
|
|
225
|
+
retryDelayMs: normalizedRetryDelayMs,
|
|
226
|
+
retryAfterHeader: response.headers.get("retry-after"),
|
|
227
|
+
});
|
|
228
|
+
await sleep(delayMs);
|
|
229
|
+
continue;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error instanceof SentinelayerApiError) {
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const isAbortError = Boolean(error && typeof error === "object" && error.name === "AbortError");
|
|
236
|
+
const normalizedError = new SentinelayerApiError(
|
|
237
|
+
isAbortError ? "Request timed out." : (error instanceof Error ? error.message : String(error || "Request failed")),
|
|
238
|
+
{
|
|
239
|
+
status: isAbortError ? 408 : 503,
|
|
240
|
+
code: isAbortError ? "TIMEOUT" : "NETWORK_ERROR",
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
if (attempt >= normalizedMaxRetries) {
|
|
245
|
+
recordFailureForCircuit();
|
|
246
|
+
throw normalizedError;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
lastRetryableError = normalizedError;
|
|
250
|
+
const delayMs = computeBackoffMs({
|
|
251
|
+
attempt: attempt + 1,
|
|
252
|
+
retryDelayMs: normalizedRetryDelayMs,
|
|
253
|
+
retryAfterHeader: null,
|
|
254
|
+
});
|
|
255
|
+
await sleep(delayMs);
|
|
256
|
+
continue;
|
|
257
|
+
} finally {
|
|
258
|
+
clearTimeout(timeout);
|
|
259
|
+
await sleep(0);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (lastRetryableError instanceof SentinelayerApiError) {
|
|
264
|
+
throw lastRetryableError;
|
|
265
|
+
}
|
|
266
|
+
throw new SentinelayerApiError("Request failed without a terminal response.", {
|
|
267
|
+
status: 503,
|
|
268
|
+
code: "NETWORK_ERROR",
|
|
269
|
+
});
|
|
270
|
+
}
|