@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.
Files changed (189) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
  3. package/NOTICE +76 -0
  4. package/README.md +84 -0
  5. package/TRADEMARKS.md +41 -0
  6. package/bin/whoisleuth.mjs +35 -0
  7. package/cli/archive-inspect.mjs +198 -0
  8. package/cli/arguments.mjs +1022 -0
  9. package/cli/artifact-verify.mjs +210 -0
  10. package/cli/bulk-checkpoint.mjs +218 -0
  11. package/cli/bulk-command-runner.mjs +109 -0
  12. package/cli/bulk-output.mjs +112 -0
  13. package/cli/bulk.mjs +133 -0
  14. package/cli/compare.mjs +238 -0
  15. package/cli/completion.mjs +175 -0
  16. package/cli/csv.mjs +1 -0
  17. package/cli/discover.mjs +50 -0
  18. package/cli/discovery-command-runner.mjs +36 -0
  19. package/cli/discovery-observation-snapshot.mjs +315 -0
  20. package/cli/discovery-scan-command-runner.mjs +141 -0
  21. package/cli/discovery-scan.mjs +325 -0
  22. package/cli/discovery-snapshot.mjs +133 -0
  23. package/cli/discovery-workflow.mjs +73 -0
  24. package/cli/doctor.mjs +137 -0
  25. package/cli/errors.mjs +21 -0
  26. package/cli/evidence-command-arguments.mjs +145 -0
  27. package/cli/evidence-command-runner.mjs +106 -0
  28. package/cli/evidence-signing.mjs +215 -0
  29. package/cli/exit-codes.mjs +9 -0
  30. package/cli/export-evidence.mjs +28 -0
  31. package/cli/formatters/evidence-report.mjs +3 -0
  32. package/cli/formatters/html.mjs +69 -0
  33. package/cli/formatters/json.mjs +135 -0
  34. package/cli/formatters/markdown.mjs +3 -0
  35. package/cli/formatters/terminal.mjs +658 -0
  36. package/cli/http.mjs +27 -0
  37. package/cli/lookup-command-runner.mjs +87 -0
  38. package/cli/lookup-diff.mjs +171 -0
  39. package/cli/mail-review.mjs +204 -0
  40. package/cli/manual.mjs +34 -0
  41. package/cli/network-command-runner.mjs +88 -0
  42. package/cli/output-file.mjs +97 -0
  43. package/cli/page-compare.mjs +123 -0
  44. package/cli/posture.mjs +20 -0
  45. package/cli/progress-events.mjs +53 -0
  46. package/cli/progress.mjs +56 -0
  47. package/cli/registry-access.mjs +3 -0
  48. package/cli/registry-support.mjs +120 -0
  49. package/cli/risk-calibration.mjs +294 -0
  50. package/cli/runner-types.mjs +1 -0
  51. package/cli/runner.mjs +726 -0
  52. package/cli/saved-lookup.mjs +87 -0
  53. package/cli/source-reliability.mjs +727 -0
  54. package/cli/strict-exit.mjs +55 -0
  55. package/cli/terminal-presentation.mjs +147 -0
  56. package/docs/cli.md +827 -0
  57. package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
  58. package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
  59. package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
  60. package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
  61. package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
  62. package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
  63. package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
  64. package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
  65. package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
  66. package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
  67. package/frontend/src/lib/analysis/bulk-sort.js +49 -0
  68. package/frontend/src/lib/analysis/campaign-model.js +265 -0
  69. package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
  70. package/frontend/src/lib/analysis/case-migration-model.js +248 -0
  71. package/frontend/src/lib/analysis/case-model.js +5 -0
  72. package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
  73. package/frontend/src/lib/analysis/case-record-core.js +232 -0
  74. package/frontend/src/lib/analysis/case-record-model.js +7 -0
  75. package/frontend/src/lib/analysis/case-record-operations.js +224 -0
  76. package/frontend/src/lib/analysis/case-response-model.js +640 -0
  77. package/frontend/src/lib/analysis/case-response-packet.js +573 -0
  78. package/frontend/src/lib/analysis/case-storage-model.js +93 -0
  79. package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
  80. package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
  81. package/frontend/src/lib/analysis/http-summary.js +165 -0
  82. package/frontend/src/lib/analysis/investigation-guide.js +500 -0
  83. package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
  84. package/frontend/src/lib/analysis/page-baseline.js +272 -0
  85. package/frontend/src/lib/analysis/page-similarity.js +179 -0
  86. package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
  87. package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
  88. package/frontend/src/lib/analysis/scoring.js +135 -0
  89. package/frontend/src/lib/analysis/shortlist-model.js +209 -0
  90. package/frontend/src/lib/analysis/utils.js +265 -0
  91. package/frontend/src/lib/analysis/watchlist-history.js +643 -0
  92. package/frontend/src/lib/analysis/watchlist-store.js +127 -0
  93. package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
  94. package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
  95. package/frontend/src/lib/analysis/workspace-archive.js +472 -0
  96. package/lib/abort.mjs +26 -0
  97. package/lib/abusech-auth.mjs +19 -0
  98. package/lib/availability.mjs +716 -0
  99. package/lib/bounded-contract-normalizers.mjs +61 -0
  100. package/lib/browser-library-profile.mjs +310 -0
  101. package/lib/bulk-comparison-evidence.mjs +83 -0
  102. package/lib/classify.mjs +93 -0
  103. package/lib/client-behavior-profile.mjs +83 -0
  104. package/lib/credential-surface-profile.mjs +66 -0
  105. package/lib/csv.mjs +23 -0
  106. package/lib/ct-query.mjs +27 -0
  107. package/lib/ct-search.mjs +393 -0
  108. package/lib/distributed-operation-budget.mjs +461 -0
  109. package/lib/dns-delegation-health.mjs +344 -0
  110. package/lib/dns-intelligence.mjs +739 -0
  111. package/lib/dns-mx.mjs +24 -0
  112. package/lib/dns-resolver-selection.mjs +33 -0
  113. package/lib/domain-posture-analysis.mjs +254 -0
  114. package/lib/domain-posture-parsers.mjs +358 -0
  115. package/lib/domain-posture.mjs +745 -0
  116. package/lib/error-detail.mjs +10 -0
  117. package/lib/evidence-export.mjs +431 -0
  118. package/lib/evidence-report-markdown.mjs +71 -0
  119. package/lib/evidence-report.mjs +391 -0
  120. package/lib/external-intelligence-risk.mjs +108 -0
  121. package/lib/favicon.mjs +146 -0
  122. package/lib/feature-policy.mjs +70 -0
  123. package/lib/generated/retire-browser-catalog.mjs +8724 -0
  124. package/lib/generated/unicode-confusables-17.mjs +93 -0
  125. package/lib/hostname.mjs +38 -0
  126. package/lib/html-signals.mjs +680 -0
  127. package/lib/http-intelligence.mjs +232 -0
  128. package/lib/idn-confusable-policy.mjs +86 -0
  129. package/lib/idn-confusables.mjs +465 -0
  130. package/lib/lookup-cache.mjs +130 -0
  131. package/lib/lookup-progress.mjs +291 -0
  132. package/lib/lookup-source-progress.mjs +109 -0
  133. package/lib/lookup-threat-provider-inventory.mjs +13 -0
  134. package/lib/lookup.mjs +592 -0
  135. package/lib/observation.mjs +114 -0
  136. package/lib/observed-network-context.mjs +355 -0
  137. package/lib/operation-budget.mjs +489 -0
  138. package/lib/page-fingerprints.mjs +481 -0
  139. package/lib/page-role-profile.mjs +117 -0
  140. package/lib/perceptual-hash.mjs +451 -0
  141. package/lib/rdap-attempts.mjs +34 -0
  142. package/lib/rdap-bootstrap.mjs +173 -0
  143. package/lib/rdap-capabilities.mjs +292 -0
  144. package/lib/rdap-client.mjs +125 -0
  145. package/lib/rdap-normalization.mjs +691 -0
  146. package/lib/rdap-registrar.mjs +196 -0
  147. package/lib/rdap-transport.mjs +39 -0
  148. package/lib/rdap-types.mjs +1 -0
  149. package/lib/rdap-validation.mjs +94 -0
  150. package/lib/rdap.mjs +16 -0
  151. package/lib/registry-access.mjs +32 -0
  152. package/lib/registry-capabilities.mjs +117 -0
  153. package/lib/registry-capability-catalogue.mjs +1206 -0
  154. package/lib/registry-comparison.mjs +318 -0
  155. package/lib/registry-dates.mjs +190 -0
  156. package/lib/registry-insights.mjs +325 -0
  157. package/lib/response-policy.mjs +256 -0
  158. package/lib/risk-scoring.mjs +164 -0
  159. package/lib/safe-fetch.mjs +446 -0
  160. package/lib/security-txt.mjs +359 -0
  161. package/lib/service-binding-dns.mjs +608 -0
  162. package/lib/sslbl-certificates.generated.mjs +143 -0
  163. package/lib/sslbl-intelligence.mjs +241 -0
  164. package/lib/static-html-analysis.mjs +340 -0
  165. package/lib/structured-data-identity.mjs +323 -0
  166. package/lib/threat-intelligence-contract.mjs +5 -0
  167. package/lib/threat-intelligence-definition-registry.mjs +34 -0
  168. package/lib/threat-intelligence-fixtures.mjs +82 -0
  169. package/lib/threat-intelligence-manifests.mjs +512 -0
  170. package/lib/threat-intelligence-runtime.mjs +662 -0
  171. package/lib/threat-intelligence-targets.mjs +168 -0
  172. package/lib/threat-intelligence-types.mjs +3 -0
  173. package/lib/threatfox-intelligence.mjs +307 -0
  174. package/lib/tls-intelligence.mjs +814 -0
  175. package/lib/typosquat-generator.mjs +884 -0
  176. package/lib/urlhaus-intelligence.mjs +361 -0
  177. package/lib/urlscan-intelligence.mjs +349 -0
  178. package/lib/website-security-posture.mjs +325 -0
  179. package/lib/website-technology.mjs +437 -0
  180. package/lib/whois-authority.mjs +98 -0
  181. package/lib/whois-chain.mjs +261 -0
  182. package/lib/whois-contacts.mjs +163 -0
  183. package/lib/whois-contracts.mjs +3 -0
  184. package/lib/whois-normalization.mjs +27 -0
  185. package/lib/whois-transport.mjs +100 -0
  186. package/lib/whois-values.mjs +92 -0
  187. package/lib/whois.mjs +1187 -0
  188. package/package.json +55 -0
  189. 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, };