@slicedearth/whoisleuth-cli 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
- package/NOTICE +76 -0
- package/README.md +84 -0
- package/TRADEMARKS.md +41 -0
- package/bin/whoisleuth.mjs +35 -0
- package/cli/archive-inspect.mjs +198 -0
- package/cli/arguments.mjs +1022 -0
- package/cli/artifact-verify.mjs +210 -0
- package/cli/bulk-checkpoint.mjs +218 -0
- package/cli/bulk-command-runner.mjs +109 -0
- package/cli/bulk-output.mjs +112 -0
- package/cli/bulk.mjs +133 -0
- package/cli/compare.mjs +238 -0
- package/cli/completion.mjs +175 -0
- package/cli/csv.mjs +1 -0
- package/cli/discover.mjs +50 -0
- package/cli/discovery-command-runner.mjs +36 -0
- package/cli/discovery-observation-snapshot.mjs +315 -0
- package/cli/discovery-scan-command-runner.mjs +141 -0
- package/cli/discovery-scan.mjs +325 -0
- package/cli/discovery-snapshot.mjs +133 -0
- package/cli/discovery-workflow.mjs +73 -0
- package/cli/doctor.mjs +137 -0
- package/cli/errors.mjs +21 -0
- package/cli/evidence-command-arguments.mjs +145 -0
- package/cli/evidence-command-runner.mjs +106 -0
- package/cli/evidence-signing.mjs +215 -0
- package/cli/exit-codes.mjs +9 -0
- package/cli/export-evidence.mjs +28 -0
- package/cli/formatters/evidence-report.mjs +3 -0
- package/cli/formatters/html.mjs +69 -0
- package/cli/formatters/json.mjs +135 -0
- package/cli/formatters/markdown.mjs +3 -0
- package/cli/formatters/terminal.mjs +658 -0
- package/cli/http.mjs +27 -0
- package/cli/lookup-command-runner.mjs +87 -0
- package/cli/lookup-diff.mjs +171 -0
- package/cli/mail-review.mjs +204 -0
- package/cli/manual.mjs +34 -0
- package/cli/network-command-runner.mjs +88 -0
- package/cli/output-file.mjs +97 -0
- package/cli/page-compare.mjs +123 -0
- package/cli/posture.mjs +20 -0
- package/cli/progress-events.mjs +53 -0
- package/cli/progress.mjs +56 -0
- package/cli/registry-access.mjs +3 -0
- package/cli/registry-support.mjs +120 -0
- package/cli/risk-calibration.mjs +294 -0
- package/cli/runner-types.mjs +1 -0
- package/cli/runner.mjs +726 -0
- package/cli/saved-lookup.mjs +87 -0
- package/cli/source-reliability.mjs +727 -0
- package/cli/strict-exit.mjs +55 -0
- package/cli/terminal-presentation.mjs +147 -0
- package/docs/cli.md +827 -0
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
- package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
- package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
- package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
- package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
- package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
- package/frontend/src/lib/analysis/bulk-sort.js +49 -0
- package/frontend/src/lib/analysis/campaign-model.js +265 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
- package/frontend/src/lib/analysis/case-migration-model.js +248 -0
- package/frontend/src/lib/analysis/case-model.js +5 -0
- package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
- package/frontend/src/lib/analysis/case-record-core.js +232 -0
- package/frontend/src/lib/analysis/case-record-model.js +7 -0
- package/frontend/src/lib/analysis/case-record-operations.js +224 -0
- package/frontend/src/lib/analysis/case-response-model.js +640 -0
- package/frontend/src/lib/analysis/case-response-packet.js +573 -0
- package/frontend/src/lib/analysis/case-storage-model.js +93 -0
- package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
- package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
- package/frontend/src/lib/analysis/http-summary.js +165 -0
- package/frontend/src/lib/analysis/investigation-guide.js +500 -0
- package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
- package/frontend/src/lib/analysis/page-baseline.js +272 -0
- package/frontend/src/lib/analysis/page-similarity.js +179 -0
- package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
- package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
- package/frontend/src/lib/analysis/scoring.js +135 -0
- package/frontend/src/lib/analysis/shortlist-model.js +209 -0
- package/frontend/src/lib/analysis/utils.js +265 -0
- package/frontend/src/lib/analysis/watchlist-history.js +643 -0
- package/frontend/src/lib/analysis/watchlist-store.js +127 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
- package/frontend/src/lib/analysis/workspace-archive.js +472 -0
- package/lib/abort.mjs +26 -0
- package/lib/abusech-auth.mjs +19 -0
- package/lib/availability.mjs +716 -0
- package/lib/bounded-contract-normalizers.mjs +61 -0
- package/lib/browser-library-profile.mjs +310 -0
- package/lib/bulk-comparison-evidence.mjs +83 -0
- package/lib/classify.mjs +93 -0
- package/lib/client-behavior-profile.mjs +83 -0
- package/lib/credential-surface-profile.mjs +66 -0
- package/lib/csv.mjs +23 -0
- package/lib/ct-query.mjs +27 -0
- package/lib/ct-search.mjs +393 -0
- package/lib/distributed-operation-budget.mjs +461 -0
- package/lib/dns-delegation-health.mjs +344 -0
- package/lib/dns-intelligence.mjs +739 -0
- package/lib/dns-mx.mjs +24 -0
- package/lib/dns-resolver-selection.mjs +33 -0
- package/lib/domain-posture-analysis.mjs +254 -0
- package/lib/domain-posture-parsers.mjs +358 -0
- package/lib/domain-posture.mjs +745 -0
- package/lib/error-detail.mjs +10 -0
- package/lib/evidence-export.mjs +431 -0
- package/lib/evidence-report-markdown.mjs +71 -0
- package/lib/evidence-report.mjs +391 -0
- package/lib/external-intelligence-risk.mjs +108 -0
- package/lib/favicon.mjs +146 -0
- package/lib/feature-policy.mjs +70 -0
- package/lib/generated/retire-browser-catalog.mjs +8724 -0
- package/lib/generated/unicode-confusables-17.mjs +93 -0
- package/lib/hostname.mjs +38 -0
- package/lib/html-signals.mjs +680 -0
- package/lib/http-intelligence.mjs +232 -0
- package/lib/idn-confusable-policy.mjs +86 -0
- package/lib/idn-confusables.mjs +465 -0
- package/lib/lookup-cache.mjs +130 -0
- package/lib/lookup-progress.mjs +291 -0
- package/lib/lookup-source-progress.mjs +109 -0
- package/lib/lookup-threat-provider-inventory.mjs +13 -0
- package/lib/lookup.mjs +592 -0
- package/lib/observation.mjs +114 -0
- package/lib/observed-network-context.mjs +355 -0
- package/lib/operation-budget.mjs +489 -0
- package/lib/page-fingerprints.mjs +481 -0
- package/lib/page-role-profile.mjs +117 -0
- package/lib/perceptual-hash.mjs +451 -0
- package/lib/rdap-attempts.mjs +34 -0
- package/lib/rdap-bootstrap.mjs +173 -0
- package/lib/rdap-capabilities.mjs +292 -0
- package/lib/rdap-client.mjs +125 -0
- package/lib/rdap-normalization.mjs +691 -0
- package/lib/rdap-registrar.mjs +196 -0
- package/lib/rdap-transport.mjs +39 -0
- package/lib/rdap-types.mjs +1 -0
- package/lib/rdap-validation.mjs +94 -0
- package/lib/rdap.mjs +16 -0
- package/lib/registry-access.mjs +32 -0
- package/lib/registry-capabilities.mjs +117 -0
- package/lib/registry-capability-catalogue.mjs +1206 -0
- package/lib/registry-comparison.mjs +318 -0
- package/lib/registry-dates.mjs +190 -0
- package/lib/registry-insights.mjs +325 -0
- package/lib/response-policy.mjs +256 -0
- package/lib/risk-scoring.mjs +164 -0
- package/lib/safe-fetch.mjs +446 -0
- package/lib/security-txt.mjs +359 -0
- package/lib/service-binding-dns.mjs +608 -0
- package/lib/sslbl-certificates.generated.mjs +143 -0
- package/lib/sslbl-intelligence.mjs +241 -0
- package/lib/static-html-analysis.mjs +340 -0
- package/lib/structured-data-identity.mjs +323 -0
- package/lib/threat-intelligence-contract.mjs +5 -0
- package/lib/threat-intelligence-definition-registry.mjs +34 -0
- package/lib/threat-intelligence-fixtures.mjs +82 -0
- package/lib/threat-intelligence-manifests.mjs +512 -0
- package/lib/threat-intelligence-runtime.mjs +662 -0
- package/lib/threat-intelligence-targets.mjs +168 -0
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +307 -0
- package/lib/tls-intelligence.mjs +814 -0
- package/lib/typosquat-generator.mjs +884 -0
- package/lib/urlhaus-intelligence.mjs +361 -0
- package/lib/urlscan-intelligence.mjs +349 -0
- package/lib/website-security-posture.mjs +325 -0
- package/lib/website-technology.mjs +437 -0
- package/lib/whois-authority.mjs +98 -0
- package/lib/whois-chain.mjs +261 -0
- package/lib/whois-contacts.mjs +163 -0
- package/lib/whois-contracts.mjs +3 -0
- package/lib/whois-normalization.mjs +27 -0
- package/lib/whois-transport.mjs +100 -0
- package/lib/whois-values.mjs +92 -0
- package/lib/whois.mjs +1187 -0
- package/package.json +55 -0
- package/third-party-notices.txt +5571 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
// Optional distributed concurrency leases and fixed-window operation counters
|
|
2
|
+
// backed by an HTTPS Redis-compatible REST endpoint. This module stores only
|
|
3
|
+
// operation classes/features, fixed bucket identifiers, integer counts, opaque
|
|
4
|
+
// random lease ids, and a one-way hash of the already-opaque session
|
|
5
|
+
// fingerprint. It never stores query targets, authentication tokens, evidence,
|
|
6
|
+
// or response content.
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
|
|
9
|
+
const DEFAULT_NAMESPACE = 'whoisleuth:operation-budget:v1';
|
|
10
|
+
const DEFAULT_LEASE_TTL_MS = 5 * 60 * 1000;
|
|
11
|
+
const REQUEST_TIMEOUT_MS = 4_000;
|
|
12
|
+
const MAX_RESPONSE_BYTES = 16 * 1024;
|
|
13
|
+
const PROVIDER_RETRY_AFTER_SECONDS = 5;
|
|
14
|
+
const DAY_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
const THIRTY_DAY_WINDOW_MS = 30 * DAY_WINDOW_MS;
|
|
16
|
+
const MAX_USAGE_LIMIT = 1_000_000_000;
|
|
17
|
+
function isSafeInteger(value) {
|
|
18
|
+
return typeof value === 'number' && Number.isSafeInteger(value);
|
|
19
|
+
}
|
|
20
|
+
const ACQUIRE_SCRIPT = `
|
|
21
|
+
local current_time = redis.call('TIME')
|
|
22
|
+
local now_ms = (tonumber(current_time[1]) * 1000) + math.floor(tonumber(current_time[2]) / 1000)
|
|
23
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now_ms)
|
|
24
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now_ms)
|
|
25
|
+
local runtime_count = redis.call('ZCARD', KEYS[1])
|
|
26
|
+
local session_count = redis.call('ZCARD', KEYS[2])
|
|
27
|
+
if session_count >= tonumber(ARGV[2]) then
|
|
28
|
+
return {0, 1, runtime_count, session_count}
|
|
29
|
+
end
|
|
30
|
+
if runtime_count >= tonumber(ARGV[1]) then
|
|
31
|
+
return {0, 2, runtime_count, session_count}
|
|
32
|
+
end
|
|
33
|
+
local expires_at = now_ms + tonumber(ARGV[3])
|
|
34
|
+
redis.call('ZADD', KEYS[1], expires_at, ARGV[4])
|
|
35
|
+
redis.call('ZADD', KEYS[2], expires_at, ARGV[4])
|
|
36
|
+
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[3]) + 60000)
|
|
37
|
+
redis.call('PEXPIRE', KEYS[2], tonumber(ARGV[3]) + 60000)
|
|
38
|
+
return {1, 0, runtime_count + 1, session_count + 1}
|
|
39
|
+
`.trim();
|
|
40
|
+
const ACQUIRE_WITH_USAGE_SCRIPT = `
|
|
41
|
+
local current_time = redis.call('TIME')
|
|
42
|
+
local now_ms = (tonumber(current_time[1]) * 1000) + math.floor(tonumber(current_time[2]) / 1000)
|
|
43
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now_ms)
|
|
44
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now_ms)
|
|
45
|
+
local runtime_count = redis.call('ZCARD', KEYS[1])
|
|
46
|
+
local session_count = redis.call('ZCARD', KEYS[2])
|
|
47
|
+
if session_count >= tonumber(ARGV[2]) then
|
|
48
|
+
return {0, 1, runtime_count, session_count, 0, 0, 0, 0, 1}
|
|
49
|
+
end
|
|
50
|
+
if runtime_count >= tonumber(ARGV[1]) then
|
|
51
|
+
return {0, 2, runtime_count, session_count, 0, 0, 0, 0, 1}
|
|
52
|
+
end
|
|
53
|
+
local day_ms = ${DAY_WINDOW_MS}
|
|
54
|
+
local thirty_day_ms = ${THIRTY_DAY_WINDOW_MS}
|
|
55
|
+
local day_bucket = math.floor(now_ms / day_ms)
|
|
56
|
+
local thirty_day_bucket = math.floor(now_ms / thirty_day_ms)
|
|
57
|
+
local day_reset_ms = (day_bucket + 1) * day_ms
|
|
58
|
+
local thirty_day_reset_ms = (thirty_day_bucket + 1) * thirty_day_ms
|
|
59
|
+
local global_day_key = KEYS[3] .. ':' .. day_bucket
|
|
60
|
+
local global_thirty_day_key = KEYS[4] .. ':' .. thirty_day_bucket
|
|
61
|
+
local feature_day_key = KEYS[5] .. ':' .. day_bucket
|
|
62
|
+
local feature_thirty_day_key = KEYS[6] .. ':' .. thirty_day_bucket
|
|
63
|
+
local function read_counter(key)
|
|
64
|
+
local raw = redis.call('GET', key)
|
|
65
|
+
if not raw then return 0 end
|
|
66
|
+
if not string.match(raw, '^%d+$') then return nil end
|
|
67
|
+
local parsed = tonumber(raw)
|
|
68
|
+
if not parsed or parsed < 0 or parsed > ${MAX_USAGE_LIMIT} then return nil end
|
|
69
|
+
return parsed
|
|
70
|
+
end
|
|
71
|
+
local global_day_count = read_counter(global_day_key)
|
|
72
|
+
local global_thirty_day_count = read_counter(global_thirty_day_key)
|
|
73
|
+
local feature_day_count = read_counter(feature_day_key)
|
|
74
|
+
local feature_thirty_day_count = read_counter(feature_thirty_day_key)
|
|
75
|
+
if not global_day_count or not global_thirty_day_count or not feature_day_count or not feature_thirty_day_count then
|
|
76
|
+
return {-1, 0, runtime_count, session_count, 0, 0, 0, 0, 1}
|
|
77
|
+
end
|
|
78
|
+
if global_day_count >= tonumber(ARGV[5]) then
|
|
79
|
+
return {0, 3, runtime_count, session_count, global_day_count, global_thirty_day_count, feature_day_count, feature_thirty_day_count, math.max(1, math.ceil((day_reset_ms - now_ms) / 1000))}
|
|
80
|
+
end
|
|
81
|
+
if global_thirty_day_count >= tonumber(ARGV[6]) then
|
|
82
|
+
return {0, 4, runtime_count, session_count, global_day_count, global_thirty_day_count, feature_day_count, feature_thirty_day_count, math.max(1, math.ceil((thirty_day_reset_ms - now_ms) / 1000))}
|
|
83
|
+
end
|
|
84
|
+
if tonumber(ARGV[7]) > 0 and feature_day_count >= tonumber(ARGV[7]) then
|
|
85
|
+
return {0, 5, runtime_count, session_count, global_day_count, global_thirty_day_count, feature_day_count, feature_thirty_day_count, math.max(1, math.ceil((day_reset_ms - now_ms) / 1000))}
|
|
86
|
+
end
|
|
87
|
+
if tonumber(ARGV[8]) > 0 and feature_thirty_day_count >= tonumber(ARGV[8]) then
|
|
88
|
+
return {0, 6, runtime_count, session_count, global_day_count, global_thirty_day_count, feature_day_count, feature_thirty_day_count, math.max(1, math.ceil((thirty_day_reset_ms - now_ms) / 1000))}
|
|
89
|
+
end
|
|
90
|
+
local expires_at = now_ms + tonumber(ARGV[3])
|
|
91
|
+
redis.call('ZADD', KEYS[1], expires_at, ARGV[4])
|
|
92
|
+
redis.call('ZADD', KEYS[2], expires_at, ARGV[4])
|
|
93
|
+
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[3]) + 60000)
|
|
94
|
+
redis.call('PEXPIRE', KEYS[2], tonumber(ARGV[3]) + 60000)
|
|
95
|
+
global_day_count = redis.call('INCR', global_day_key)
|
|
96
|
+
global_thirty_day_count = redis.call('INCR', global_thirty_day_key)
|
|
97
|
+
feature_day_count = redis.call('INCR', feature_day_key)
|
|
98
|
+
feature_thirty_day_count = redis.call('INCR', feature_thirty_day_key)
|
|
99
|
+
redis.call('PEXPIREAT', global_day_key, day_reset_ms + 60000)
|
|
100
|
+
redis.call('PEXPIREAT', feature_day_key, day_reset_ms + 60000)
|
|
101
|
+
redis.call('PEXPIREAT', global_thirty_day_key, thirty_day_reset_ms + 60000)
|
|
102
|
+
redis.call('PEXPIREAT', feature_thirty_day_key, thirty_day_reset_ms + 60000)
|
|
103
|
+
return {1, 0, runtime_count + 1, session_count + 1, global_day_count, global_thirty_day_count, feature_day_count, feature_thirty_day_count, 0}
|
|
104
|
+
`.trim();
|
|
105
|
+
const RELEASE_SCRIPT = `
|
|
106
|
+
local runtime_removed = redis.call('ZREM', KEYS[1], ARGV[1])
|
|
107
|
+
local session_removed = redis.call('ZREM', KEYS[2], ARGV[1])
|
|
108
|
+
if redis.call('ZCARD', KEYS[1]) == 0 then redis.call('DEL', KEYS[1]) end
|
|
109
|
+
if redis.call('ZCARD', KEYS[2]) == 0 then redis.call('DEL', KEYS[2]) end
|
|
110
|
+
return {runtime_removed, session_removed}
|
|
111
|
+
`.trim();
|
|
112
|
+
const STATUS_SCRIPT = `
|
|
113
|
+
local current_time = redis.call('TIME')
|
|
114
|
+
local now_ms = (tonumber(current_time[1]) * 1000) + math.floor(tonumber(current_time[2]) / 1000)
|
|
115
|
+
local counts = {}
|
|
116
|
+
for index, key in ipairs(KEYS) do
|
|
117
|
+
redis.call('ZREMRANGEBYSCORE', key, '-inf', now_ms)
|
|
118
|
+
counts[index] = redis.call('ZCARD', key)
|
|
119
|
+
if counts[index] == 0 then redis.call('DEL', key) end
|
|
120
|
+
end
|
|
121
|
+
return counts
|
|
122
|
+
`.trim();
|
|
123
|
+
function normalizedRestUrl(value) {
|
|
124
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 2048) {
|
|
125
|
+
throw new Error('A bounded distributed-budget REST URL is required');
|
|
126
|
+
}
|
|
127
|
+
const parsed = new URL(value.trim());
|
|
128
|
+
if (parsed.protocol !== 'https:' || !parsed.hostname || parsed.username || parsed.password) {
|
|
129
|
+
throw new Error('The distributed-budget REST URL must be an HTTPS origin without credentials');
|
|
130
|
+
}
|
|
131
|
+
if (parsed.hostname !== 'upstash.io' && !parsed.hostname.endsWith('.upstash.io')) {
|
|
132
|
+
throw new Error('The distributed-budget REST URL must use the configured service endpoint domain');
|
|
133
|
+
}
|
|
134
|
+
if ((parsed.pathname && parsed.pathname !== '/') || parsed.search || parsed.hash) {
|
|
135
|
+
throw new Error('The distributed-budget REST URL must not include a path, query, or fragment');
|
|
136
|
+
}
|
|
137
|
+
return parsed.origin;
|
|
138
|
+
}
|
|
139
|
+
function normalizedToken(value) {
|
|
140
|
+
if (typeof value !== 'string' || !value || value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
141
|
+
throw new Error('A bounded distributed-budget REST token is required');
|
|
142
|
+
}
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
function normalizedNamespace(value = DEFAULT_NAMESPACE) {
|
|
146
|
+
if (typeof value !== 'string' || !/^[a-z0-9:_-]{1,64}$/i.test(value)) {
|
|
147
|
+
throw new Error('The distributed-budget namespace must use 1-64 letters, digits, colons, underscores, or hyphens');
|
|
148
|
+
}
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
function normalizedLeaseTtl(value = DEFAULT_LEASE_TTL_MS) {
|
|
152
|
+
const ttl = Number(value);
|
|
153
|
+
if (!Number.isSafeInteger(ttl) || ttl < 30_000 || ttl > 15 * 60 * 1000) {
|
|
154
|
+
throw new Error('The distributed-budget lease TTL must be between 30 seconds and 15 minutes');
|
|
155
|
+
}
|
|
156
|
+
return ttl;
|
|
157
|
+
}
|
|
158
|
+
function normalizedLimits(value) {
|
|
159
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
160
|
+
throw new Error('Distributed operation limits are required');
|
|
161
|
+
}
|
|
162
|
+
const entries = Object.entries(value);
|
|
163
|
+
if (!entries.length)
|
|
164
|
+
throw new Error('At least one distributed operation limit is required');
|
|
165
|
+
const limits = {};
|
|
166
|
+
for (const [id, configured] of entries) {
|
|
167
|
+
const limit = configured;
|
|
168
|
+
if (!/^[a-z0-9_]{1,50}$/.test(id)
|
|
169
|
+
|| !limit
|
|
170
|
+
|| !Number.isSafeInteger(limit.session)
|
|
171
|
+
|| !Number.isSafeInteger(limit.runtime)
|
|
172
|
+
|| limit.session < 1
|
|
173
|
+
|| limit.runtime < limit.session
|
|
174
|
+
|| limit.runtime > 1000) {
|
|
175
|
+
throw new Error(`Invalid distributed operation limits for ${id}`);
|
|
176
|
+
}
|
|
177
|
+
limits[id] = Object.freeze({ session: limit.session, runtime: limit.runtime });
|
|
178
|
+
}
|
|
179
|
+
return Object.freeze(limits);
|
|
180
|
+
}
|
|
181
|
+
function normalizedUsageLimits(value) {
|
|
182
|
+
if (value == null)
|
|
183
|
+
return null;
|
|
184
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
185
|
+
throw new Error('Distributed usage limits must be an object');
|
|
186
|
+
}
|
|
187
|
+
const record = value;
|
|
188
|
+
const daily = record.daily;
|
|
189
|
+
const monthly = record.monthly;
|
|
190
|
+
if (!isSafeInteger(daily)
|
|
191
|
+
|| !isSafeInteger(monthly)
|
|
192
|
+
|| daily < 1
|
|
193
|
+
|| monthly < daily
|
|
194
|
+
|| monthly > MAX_USAGE_LIMIT) {
|
|
195
|
+
throw new Error('Invalid distributed global usage limits');
|
|
196
|
+
}
|
|
197
|
+
const rawFeatures = record.features == null ? {} : record.features;
|
|
198
|
+
if (!rawFeatures || typeof rawFeatures !== 'object' || Array.isArray(rawFeatures)) {
|
|
199
|
+
throw new Error('Distributed feature usage limits must be an object');
|
|
200
|
+
}
|
|
201
|
+
const entries = Object.entries(rawFeatures)
|
|
202
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
203
|
+
if (entries.length > 50)
|
|
204
|
+
throw new Error('Too many distributed feature usage limits');
|
|
205
|
+
const features = {};
|
|
206
|
+
for (const [id, configured] of entries) {
|
|
207
|
+
const feature = configured && typeof configured === 'object' && !Array.isArray(configured)
|
|
208
|
+
? configured
|
|
209
|
+
: null;
|
|
210
|
+
const featureDaily = feature && feature.daily;
|
|
211
|
+
const featureMonthly = feature && feature.monthly;
|
|
212
|
+
if (!/^[a-z0-9_]{1,50}$/.test(id)
|
|
213
|
+
|| !isSafeInteger(featureDaily)
|
|
214
|
+
|| !isSafeInteger(featureMonthly)
|
|
215
|
+
|| featureDaily < 1
|
|
216
|
+
|| featureMonthly < featureDaily
|
|
217
|
+
|| featureDaily > daily
|
|
218
|
+
|| featureMonthly > monthly) {
|
|
219
|
+
throw new Error(`Invalid distributed feature usage limits for ${id}`);
|
|
220
|
+
}
|
|
221
|
+
features[id] = Object.freeze({ daily: featureDaily, monthly: featureMonthly });
|
|
222
|
+
}
|
|
223
|
+
return Object.freeze({
|
|
224
|
+
modelVersion: 1,
|
|
225
|
+
daily,
|
|
226
|
+
monthly,
|
|
227
|
+
features: Object.freeze(features),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
function createRestCommandClient({ url, token }, dependencies = {}) {
|
|
231
|
+
const endpoint = normalizedRestUrl(url);
|
|
232
|
+
const bearerToken = normalizedToken(token);
|
|
233
|
+
const request = dependencies.safeFetch || safeFetch;
|
|
234
|
+
const readResponse = dependencies.readTextCapped || readTextCapped;
|
|
235
|
+
return async function command(body) {
|
|
236
|
+
const controller = new AbortController();
|
|
237
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
238
|
+
timeout.unref?.();
|
|
239
|
+
let response;
|
|
240
|
+
try {
|
|
241
|
+
response = await request(endpoint, {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
redirect: 'manual',
|
|
244
|
+
headers: {
|
|
245
|
+
Authorization: `Bearer ${bearerToken}`,
|
|
246
|
+
'Content-Type': 'application/json',
|
|
247
|
+
},
|
|
248
|
+
body: JSON.stringify(body),
|
|
249
|
+
signal: controller.signal,
|
|
250
|
+
}, 0);
|
|
251
|
+
const captured = await readResponse(response, MAX_RESPONSE_BYTES);
|
|
252
|
+
if (captured.truncated)
|
|
253
|
+
throw new Error('Distributed-budget provider response exceeded its size limit');
|
|
254
|
+
let payload;
|
|
255
|
+
try {
|
|
256
|
+
payload = JSON.parse(captured.text);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
throw new Error('Distributed-budget provider returned malformed JSON');
|
|
260
|
+
}
|
|
261
|
+
if (!response.ok || !payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
262
|
+
throw new Error(`Distributed-budget provider rejected the command (HTTP ${response.status})`);
|
|
263
|
+
}
|
|
264
|
+
const payloadRecord = payload;
|
|
265
|
+
if (typeof payloadRecord.error === 'string' && payloadRecord.error) {
|
|
266
|
+
throw new Error('Distributed-budget provider rejected the command');
|
|
267
|
+
}
|
|
268
|
+
if (!Object.prototype.hasOwnProperty.call(payload, 'result')) {
|
|
269
|
+
throw new Error('Distributed-budget provider response omitted its result');
|
|
270
|
+
}
|
|
271
|
+
return payloadRecord.result;
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
clearTimeout(timeout);
|
|
275
|
+
response?.body?.cancel?.().catch(() => { });
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function classKeys(namespace, operationClass, sessionKey) {
|
|
280
|
+
const sessionHash = crypto.createHash('sha256').update(sessionKey).digest('hex');
|
|
281
|
+
return {
|
|
282
|
+
runtime: `${namespace}:runtime:${operationClass}`,
|
|
283
|
+
session: `${namespace}:session:${operationClass}:${sessionHash}`,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function usageKeys(namespace, operationFeature) {
|
|
287
|
+
return {
|
|
288
|
+
globalDay: `${namespace}:usage:global:day`,
|
|
289
|
+
globalThirtyDay: `${namespace}:usage:global:thirty_day`,
|
|
290
|
+
featureDay: `${namespace}:usage:feature:${operationFeature}:day`,
|
|
291
|
+
featureThirtyDay: `${namespace}:usage:feature:${operationFeature}:thirty_day`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function normalizedOperationFeature(context) {
|
|
295
|
+
const value = context && typeof context === 'object' ? context.operationFeature : null;
|
|
296
|
+
if (value == null)
|
|
297
|
+
return 'unattributed';
|
|
298
|
+
if (typeof value !== 'string' || !/^[a-z0-9_]{1,50}$/.test(value)) {
|
|
299
|
+
throw new Error('A bounded operation feature is required for distributed accounting');
|
|
300
|
+
}
|
|
301
|
+
return value;
|
|
302
|
+
}
|
|
303
|
+
function providerUnavailable(operationClass) {
|
|
304
|
+
return {
|
|
305
|
+
allowed: false,
|
|
306
|
+
operationClass,
|
|
307
|
+
scope: 'provider',
|
|
308
|
+
retryAfterSeconds: PROVIDER_RETRY_AFTER_SECONDS,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function validResultArray(value, minimumLength) {
|
|
312
|
+
return Array.isArray(value)
|
|
313
|
+
&& value.length >= minimumLength
|
|
314
|
+
&& value.slice(0, minimumLength).every((item) => Number.isSafeInteger(Number(item)) && Number(item) >= 0);
|
|
315
|
+
}
|
|
316
|
+
function createDistributedOperationBudget(config, dependencies = {}) {
|
|
317
|
+
const limits = normalizedLimits(config && config.limits);
|
|
318
|
+
const usageLimits = normalizedUsageLimits(config && config.usageLimits);
|
|
319
|
+
const namespace = normalizedNamespace(config.namespace);
|
|
320
|
+
const leaseTtlMs = normalizedLeaseTtl(config.leaseTtlMs);
|
|
321
|
+
const command = dependencies.command || createRestCommandClient(config, dependencies);
|
|
322
|
+
const createLeaseId = dependencies.createLeaseId || (() => crypto.randomBytes(16).toString('hex'));
|
|
323
|
+
async function acquire(operationClass, sessionKey, context = {}) {
|
|
324
|
+
const configured = limits[operationClass];
|
|
325
|
+
if (!configured)
|
|
326
|
+
throw new Error(`Unknown operation class: ${operationClass}`);
|
|
327
|
+
if (typeof sessionKey !== 'string' || !sessionKey || sessionKey.length > 256) {
|
|
328
|
+
throw new Error('A bounded session key is required');
|
|
329
|
+
}
|
|
330
|
+
const leaseId = createLeaseId();
|
|
331
|
+
if (typeof leaseId !== 'string' || !/^[a-f0-9]{32}$/i.test(leaseId)) {
|
|
332
|
+
throw new Error('Distributed operation lease ids must be 128-bit hexadecimal values');
|
|
333
|
+
}
|
|
334
|
+
const keys = classKeys(namespace, operationClass, sessionKey);
|
|
335
|
+
const operationFeature = normalizedOperationFeature(context);
|
|
336
|
+
let acquireCommand;
|
|
337
|
+
if (usageLimits) {
|
|
338
|
+
const usage = usageKeys(namespace, operationFeature);
|
|
339
|
+
const featureLimits = usageLimits.features[operationFeature];
|
|
340
|
+
acquireCommand = [
|
|
341
|
+
'EVAL',
|
|
342
|
+
ACQUIRE_WITH_USAGE_SCRIPT,
|
|
343
|
+
6,
|
|
344
|
+
keys.runtime,
|
|
345
|
+
keys.session,
|
|
346
|
+
usage.globalDay,
|
|
347
|
+
usage.globalThirtyDay,
|
|
348
|
+
usage.featureDay,
|
|
349
|
+
usage.featureThirtyDay,
|
|
350
|
+
configured.runtime,
|
|
351
|
+
configured.session,
|
|
352
|
+
leaseTtlMs,
|
|
353
|
+
leaseId,
|
|
354
|
+
usageLimits.daily,
|
|
355
|
+
usageLimits.monthly,
|
|
356
|
+
featureLimits ? featureLimits.daily : 0,
|
|
357
|
+
featureLimits ? featureLimits.monthly : 0,
|
|
358
|
+
];
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
acquireCommand = [
|
|
362
|
+
'EVAL',
|
|
363
|
+
ACQUIRE_SCRIPT,
|
|
364
|
+
2,
|
|
365
|
+
keys.runtime,
|
|
366
|
+
keys.session,
|
|
367
|
+
configured.runtime,
|
|
368
|
+
configured.session,
|
|
369
|
+
leaseTtlMs,
|
|
370
|
+
leaseId,
|
|
371
|
+
];
|
|
372
|
+
}
|
|
373
|
+
let result;
|
|
374
|
+
try {
|
|
375
|
+
result = await command(acquireCommand);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return providerUnavailable(operationClass);
|
|
379
|
+
}
|
|
380
|
+
const resultLength = usageLimits ? 9 : 4;
|
|
381
|
+
if (!validResultArray(result, resultLength))
|
|
382
|
+
return providerUnavailable(operationClass);
|
|
383
|
+
const allowed = Number(result[0]) === 1;
|
|
384
|
+
if (!allowed) {
|
|
385
|
+
const scopeCode = Number(result[1]);
|
|
386
|
+
const scopes = {
|
|
387
|
+
1: 'session',
|
|
388
|
+
2: 'runtime',
|
|
389
|
+
3: 'global_daily',
|
|
390
|
+
4: 'global_30_day',
|
|
391
|
+
5: 'feature_daily',
|
|
392
|
+
6: 'feature_30_day',
|
|
393
|
+
};
|
|
394
|
+
if (!Object.prototype.hasOwnProperty.call(scopes, scopeCode))
|
|
395
|
+
return providerUnavailable(operationClass);
|
|
396
|
+
const usageDenial = scopeCode >= 3;
|
|
397
|
+
const retryAfterSeconds = usageDenial ? Number(result[8]) : 1;
|
|
398
|
+
if (!Number.isSafeInteger(retryAfterSeconds)
|
|
399
|
+
|| retryAfterSeconds < 1
|
|
400
|
+
|| retryAfterSeconds > THIRTY_DAY_WINDOW_MS / 1000) {
|
|
401
|
+
return providerUnavailable(operationClass);
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
allowed: false,
|
|
405
|
+
operationClass,
|
|
406
|
+
...(operationFeature !== 'unattributed' ? { operationFeature } : {}),
|
|
407
|
+
scope: scopes[scopeCode],
|
|
408
|
+
retryAfterSeconds,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
let released = false;
|
|
412
|
+
return {
|
|
413
|
+
allowed: true,
|
|
414
|
+
operationClass,
|
|
415
|
+
async release() {
|
|
416
|
+
if (released)
|
|
417
|
+
return true;
|
|
418
|
+
released = true;
|
|
419
|
+
try {
|
|
420
|
+
const releaseResult = await command([
|
|
421
|
+
'EVAL',
|
|
422
|
+
RELEASE_SCRIPT,
|
|
423
|
+
2,
|
|
424
|
+
keys.runtime,
|
|
425
|
+
keys.session,
|
|
426
|
+
leaseId,
|
|
427
|
+
]);
|
|
428
|
+
return validResultArray(releaseResult, 2);
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
// The sorted-set lease expires automatically. A release outage must
|
|
432
|
+
// not replace a completed lookup result or decrement a newer lease.
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
async function status() {
|
|
439
|
+
const entries = Object.entries(limits);
|
|
440
|
+
const keys = entries.map(([operationClass]) => `${namespace}:runtime:${operationClass}`);
|
|
441
|
+
const result = await command(['EVAL', STATUS_SCRIPT, keys.length, ...keys]);
|
|
442
|
+
if (!validResultArray(result, keys.length)) {
|
|
443
|
+
throw new Error('Distributed-budget provider returned malformed status');
|
|
444
|
+
}
|
|
445
|
+
return entries.map(([id, configured], index) => ({
|
|
446
|
+
id,
|
|
447
|
+
sessionLimit: configured.session,
|
|
448
|
+
runtimeLimit: configured.runtime,
|
|
449
|
+
active: Number(result[index]),
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
mode: 'redis_rest',
|
|
454
|
+
distributed: true,
|
|
455
|
+
limits,
|
|
456
|
+
usageLimits,
|
|
457
|
+
acquire,
|
|
458
|
+
status,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
export { DEFAULT_NAMESPACE, DEFAULT_LEASE_TTL_MS, PROVIDER_RETRY_AFTER_SECONDS, DAY_WINDOW_MS, THIRTY_DAY_WINDOW_MS, ACQUIRE_SCRIPT, ACQUIRE_WITH_USAGE_SCRIPT, RELEASE_SCRIPT, STATUS_SCRIPT, normalizedRestUrl, normalizedToken, normalizedNamespace, normalizedLimits, normalizedUsageLimits, normalizedOperationFeature, createRestCommandClient, createDistributedOperationBudget, };
|