@aluvia/sdk 1.0.0 → 1.3.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 (77) hide show
  1. package/README.md +425 -256
  2. package/dist/cjs/api/account.js +10 -74
  3. package/dist/cjs/api/apiUtils.js +80 -0
  4. package/dist/cjs/api/geos.js +2 -63
  5. package/dist/cjs/api/request.js +8 -2
  6. package/dist/cjs/bin/account.js +31 -0
  7. package/dist/cjs/bin/api-helpers.js +58 -0
  8. package/dist/cjs/bin/cli.js +245 -0
  9. package/dist/cjs/bin/close.js +120 -0
  10. package/dist/cjs/bin/geos.js +10 -0
  11. package/dist/cjs/bin/mcp-helpers.js +57 -0
  12. package/dist/cjs/bin/mcp-server.js +220 -0
  13. package/dist/cjs/bin/mcp-tools.js +90 -0
  14. package/dist/cjs/bin/open.js +293 -0
  15. package/dist/cjs/bin/session.js +259 -0
  16. package/dist/cjs/client/AluviaClient.js +411 -150
  17. package/dist/cjs/client/BlockDetection.js +486 -0
  18. package/dist/cjs/client/ConfigManager.js +26 -23
  19. package/dist/cjs/client/PageLoadDetection.js +175 -0
  20. package/dist/cjs/client/ProxyServer.js +4 -2
  21. package/dist/cjs/client/logger.js +4 -0
  22. package/dist/cjs/client/rules.js +38 -49
  23. package/dist/cjs/connect.js +117 -0
  24. package/dist/cjs/errors.js +12 -1
  25. package/dist/cjs/index.js +5 -1
  26. package/dist/cjs/session/lock.js +186 -0
  27. package/dist/esm/api/account.js +2 -66
  28. package/dist/esm/api/apiUtils.js +71 -0
  29. package/dist/esm/api/geos.js +2 -63
  30. package/dist/esm/api/request.js +8 -2
  31. package/dist/esm/bin/account.js +28 -0
  32. package/dist/esm/bin/api-helpers.js +53 -0
  33. package/dist/esm/bin/cli.js +242 -0
  34. package/dist/esm/bin/close.js +117 -0
  35. package/dist/esm/bin/geos.js +7 -0
  36. package/dist/esm/bin/mcp-helpers.js +51 -0
  37. package/dist/esm/bin/mcp-server.js +185 -0
  38. package/dist/esm/bin/mcp-tools.js +78 -0
  39. package/dist/esm/bin/open.js +256 -0
  40. package/dist/esm/bin/session.js +252 -0
  41. package/dist/esm/client/AluviaClient.js +384 -156
  42. package/dist/esm/client/BlockDetection.js +482 -0
  43. package/dist/esm/client/ConfigManager.js +21 -18
  44. package/dist/esm/client/PageLoadDetection.js +171 -0
  45. package/dist/esm/client/ProxyServer.js +5 -3
  46. package/dist/esm/client/logger.js +4 -0
  47. package/dist/esm/client/rules.js +36 -49
  48. package/dist/esm/connect.js +81 -0
  49. package/dist/esm/errors.js +10 -0
  50. package/dist/esm/index.js +5 -3
  51. package/dist/esm/session/lock.js +142 -0
  52. package/dist/types/api/AluviaApi.d.ts +2 -7
  53. package/dist/types/api/account.d.ts +1 -16
  54. package/dist/types/api/apiUtils.d.ts +28 -0
  55. package/dist/types/api/geos.d.ts +1 -1
  56. package/dist/types/bin/account.d.ts +1 -0
  57. package/dist/types/bin/api-helpers.d.ts +20 -0
  58. package/dist/types/bin/cli.d.ts +2 -0
  59. package/dist/types/bin/close.d.ts +1 -0
  60. package/dist/types/bin/geos.d.ts +1 -0
  61. package/dist/types/bin/mcp-helpers.d.ts +28 -0
  62. package/dist/types/bin/mcp-server.d.ts +2 -0
  63. package/dist/types/bin/mcp-tools.d.ts +46 -0
  64. package/dist/types/bin/open.d.ts +21 -0
  65. package/dist/types/bin/session.d.ts +11 -0
  66. package/dist/types/client/AluviaClient.d.ts +51 -4
  67. package/dist/types/client/BlockDetection.d.ts +96 -0
  68. package/dist/types/client/ConfigManager.d.ts +6 -1
  69. package/dist/types/client/PageLoadDetection.d.ts +93 -0
  70. package/dist/types/client/logger.d.ts +2 -0
  71. package/dist/types/client/rules.d.ts +18 -0
  72. package/dist/types/client/types.d.ts +69 -46
  73. package/dist/types/connect.d.ts +18 -0
  74. package/dist/types/errors.d.ts +6 -0
  75. package/dist/types/index.d.ts +7 -5
  76. package/dist/types/session/lock.d.ts +43 -0
  77. package/package.json +11 -2
@@ -0,0 +1,486 @@
1
+ "use strict";
2
+ // BlockDetection - Website block detection with weighted scoring
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.BlockDetection = void 0;
5
+ const DEFAULT_CHALLENGE_SELECTORS = [
6
+ "#challenge-form",
7
+ "#challenge-running",
8
+ ".cf-browser-verification",
9
+ 'iframe[src*="recaptcha"]',
10
+ ".g-recaptcha",
11
+ "#px-captcha",
12
+ 'iframe[src*="hcaptcha"]',
13
+ ".h-captcha",
14
+ ];
15
+ const TITLE_KEYWORDS = [
16
+ "access denied",
17
+ "blocked",
18
+ "forbidden",
19
+ "security check",
20
+ "attention required",
21
+ "just a moment",
22
+ ];
23
+ const STRONG_TEXT_KEYWORDS = [
24
+ "captcha",
25
+ "access denied",
26
+ "verify you are human",
27
+ "bot detection",
28
+ ];
29
+ const WEAK_TEXT_KEYWORDS = [
30
+ "blocked",
31
+ "forbidden",
32
+ "cloudflare",
33
+ "please verify",
34
+ "unusual activity",
35
+ ];
36
+ function escapeRegex(str) {
37
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
38
+ }
39
+ const WEAK_TEXT_REGEXES = WEAK_TEXT_KEYWORDS.map((keyword) => ({
40
+ keyword,
41
+ regex: new RegExp("\\b" + escapeRegex(keyword) + "\\b", "i"),
42
+ }));
43
+ const CHALLENGE_DOMAIN_PATTERNS = [
44
+ "/cdn-cgi/challenge-platform/",
45
+ "challenges.cloudflare.com",
46
+ "geo.captcha-delivery.com",
47
+ ];
48
+ /**
49
+ * BlockDetection handles detection of website blocks, CAPTCHAs, and WAF challenges
50
+ * using a weighted scoring system across multiple signal types.
51
+ */
52
+ class BlockDetection {
53
+ constructor(config, logger) {
54
+ // Persistent block tracking
55
+ this.retriedUrls = new Set();
56
+ this.persistentHostnames = new Set();
57
+ this.logger = logger;
58
+ this.config = {
59
+ enabled: config.enabled ?? true,
60
+ challengeSelectors: config.challengeSelectors ?? DEFAULT_CHALLENGE_SELECTORS,
61
+ extraKeywords: config.extraKeywords ?? [],
62
+ extraStatusCodes: config.extraStatusCodes ?? [],
63
+ networkIdleTimeoutMs: config.networkIdleTimeoutMs ?? 3000,
64
+ autoUnblock: config.autoUnblock ?? false,
65
+ autoUnblockOnSuspected: config.autoUnblockOnSuspected ?? false,
66
+ onDetection: config.onDetection,
67
+ };
68
+ // Pre-compute for hot-path performance
69
+ this.statusCodeSet = new Set([403, 429, ...this.config.extraStatusCodes]);
70
+ this.allTitleKeywords = [...TITLE_KEYWORDS, ...this.config.extraKeywords];
71
+ }
72
+ getNetworkIdleTimeoutMs() {
73
+ return this.config.networkIdleTimeoutMs;
74
+ }
75
+ isEnabled() {
76
+ return this.config.enabled;
77
+ }
78
+ getOnDetection() {
79
+ return this.config.onDetection;
80
+ }
81
+ isAutoUnblock() {
82
+ return this.config.autoUnblock;
83
+ }
84
+ isAutoUnblockOnSuspected() {
85
+ return this.config.autoUnblockOnSuspected;
86
+ }
87
+ // --- Scoring Engine ---
88
+ computeScore(signals) {
89
+ if (signals.length === 0)
90
+ return { score: 0, blockStatus: "clear" };
91
+ const score = 1 - signals.reduce((product, s) => product * (1 - s.weight), 1);
92
+ const blockStatus = score >= 0.7 ? "blocked" : score >= 0.4 ? "suspected" : "clear";
93
+ return { score, blockStatus };
94
+ }
95
+ // --- Fast-pass Signal Detectors ---
96
+ detectHttpStatus(response) {
97
+ const status = response?.status?.() ?? 0;
98
+ if (status === 0)
99
+ return null;
100
+ if (this.statusCodeSet.has(status)) {
101
+ return {
102
+ name: `http_status_${status}`,
103
+ weight: 0.85,
104
+ details: `HTTP ${status} response`,
105
+ source: "fast",
106
+ };
107
+ }
108
+ if (status === 503) {
109
+ return {
110
+ name: "http_status_503",
111
+ weight: 0.6,
112
+ details: "HTTP 503 response",
113
+ source: "fast",
114
+ };
115
+ }
116
+ return null;
117
+ }
118
+ detectResponseHeaders(response) {
119
+ const signals = [];
120
+ if (!response)
121
+ return signals;
122
+ try {
123
+ const headers = response.headers?.() ?? {};
124
+ const cfMitigated = headers["cf-mitigated"];
125
+ if (cfMitigated &&
126
+ cfMitigated.toLowerCase().includes("challenge")) {
127
+ signals.push({
128
+ name: "waf_header_cf_mitigated",
129
+ weight: 0.9,
130
+ details: `cf-mitigated: ${cfMitigated}`,
131
+ source: "fast",
132
+ });
133
+ }
134
+ const server = headers["server"];
135
+ if (server && server.toLowerCase().includes("cloudflare")) {
136
+ signals.push({
137
+ name: "waf_header_cloudflare",
138
+ weight: 0.1,
139
+ details: `server: ${server}`,
140
+ source: "fast",
141
+ });
142
+ }
143
+ }
144
+ catch (err) {
145
+ this.logger.debug(`Detection check failed: ${err.message}`);
146
+ }
147
+ return signals;
148
+ }
149
+ // --- Full-pass Signal Detectors ---
150
+ async detectTitleKeywords(page) {
151
+ try {
152
+ const title = (await page.title()).toLowerCase();
153
+ for (const keyword of this.allTitleKeywords) {
154
+ if (title.includes(keyword.toLowerCase())) {
155
+ return {
156
+ name: "title_keyword",
157
+ weight: 0.8,
158
+ details: `Title contains "${keyword}"`,
159
+ source: "full",
160
+ };
161
+ }
162
+ }
163
+ }
164
+ catch (err) {
165
+ this.logger.debug(`Detection check failed: ${err.message}`);
166
+ }
167
+ return null;
168
+ }
169
+ async detectChallengeSelectors(page) {
170
+ try {
171
+ const selectors = this.config.challengeSelectors;
172
+ const found = await page.evaluate((sels) => {
173
+ for (const sel of sels) {
174
+ if (document.querySelector(sel))
175
+ return sel;
176
+ }
177
+ return null;
178
+ }, selectors);
179
+ if (found) {
180
+ return {
181
+ name: "challenge_selector",
182
+ weight: 0.8,
183
+ details: `Challenge selector found: ${found}`,
184
+ source: "full",
185
+ };
186
+ }
187
+ }
188
+ catch (err) {
189
+ this.logger.debug(`Detection check failed: ${err.message}`);
190
+ }
191
+ return null;
192
+ }
193
+ async detectVisibleText(page, useInnerText = false) {
194
+ const signals = [];
195
+ try {
196
+ const text = useInnerText
197
+ ? await page.evaluate(() => document.body?.innerText ?? "")
198
+ : await page.evaluate(() => document.body?.textContent ?? "");
199
+ const textLower = text.toLowerCase();
200
+ if (text.length < 50) {
201
+ signals.push({
202
+ name: "visible_text_short",
203
+ weight: 0.2,
204
+ details: `Visible text very short (${text.length} chars)`,
205
+ source: "full",
206
+ });
207
+ }
208
+ // Strong keywords (substring match, short page < 500 chars)
209
+ if (text.length < 500) {
210
+ const allStrong = [
211
+ ...STRONG_TEXT_KEYWORDS,
212
+ ...this.config.extraKeywords,
213
+ ];
214
+ for (const keyword of allStrong) {
215
+ if (textLower.includes(keyword.toLowerCase())) {
216
+ signals.push({
217
+ name: "visible_text_keyword_strong",
218
+ weight: 0.6,
219
+ details: `Strong keyword "${keyword}" on short page`,
220
+ source: "full",
221
+ });
222
+ break;
223
+ }
224
+ }
225
+ }
226
+ // Weak keywords (word-boundary match, pre-compiled regexes)
227
+ for (const { keyword, regex } of WEAK_TEXT_REGEXES) {
228
+ if (regex.test(text)) {
229
+ signals.push({
230
+ name: "visible_text_keyword_weak",
231
+ weight: 0.15,
232
+ details: `Weak keyword "${keyword}" found with word boundary`,
233
+ source: "full",
234
+ });
235
+ break;
236
+ }
237
+ }
238
+ }
239
+ catch (err) {
240
+ this.logger.debug(`Detection check failed: ${err.message}`);
241
+ }
242
+ return signals;
243
+ }
244
+ async detectTextToHtmlRatio(page) {
245
+ try {
246
+ const result = await page.evaluate(() => {
247
+ const html = document.documentElement?.outerHTML ?? "";
248
+ const text = document.body?.textContent ?? "";
249
+ return { htmlLength: html.length, textLength: text.length };
250
+ });
251
+ if (result.htmlLength >= 1000 &&
252
+ result.textLength / result.htmlLength < 0.03) {
253
+ return {
254
+ name: "low_text_ratio",
255
+ weight: 0.2,
256
+ details: `Low text/HTML ratio: ${result.textLength}/${result.htmlLength}`,
257
+ source: "full",
258
+ };
259
+ }
260
+ }
261
+ catch (err) {
262
+ this.logger.debug(`Detection check failed: ${err.message}`);
263
+ }
264
+ return null;
265
+ }
266
+ detectRedirectChain(response) {
267
+ const chain = [];
268
+ const signals = [];
269
+ try {
270
+ if (!response)
271
+ return { signals, chain };
272
+ // Walk redirect chain backwards
273
+ let req = response.request?.();
274
+ const hops = [];
275
+ while (req) {
276
+ const redirectedFrom = req.redirectedFrom?.();
277
+ if (!redirectedFrom)
278
+ break;
279
+ const redirectResponse = redirectedFrom.response?.();
280
+ hops.push({
281
+ url: redirectedFrom.url?.() ?? "",
282
+ statusCode: redirectResponse?.status?.() ?? 0,
283
+ });
284
+ req = redirectedFrom;
285
+ }
286
+ // Reverse to get chronological order
287
+ hops.reverse();
288
+ chain.push(...hops);
289
+ // Check if any hop URL matches challenge domain patterns
290
+ for (const hop of chain) {
291
+ for (const pattern of CHALLENGE_DOMAIN_PATTERNS) {
292
+ if (hop.url.includes(pattern)) {
293
+ signals.push({
294
+ name: "redirect_to_challenge",
295
+ weight: 0.7,
296
+ details: `Redirect through challenge domain: ${hop.url}`,
297
+ source: "full",
298
+ });
299
+ return { signals, chain };
300
+ }
301
+ }
302
+ }
303
+ // Also check the final response URL
304
+ const finalUrl = response.url?.() ?? "";
305
+ for (const pattern of CHALLENGE_DOMAIN_PATTERNS) {
306
+ if (finalUrl.includes(pattern)) {
307
+ signals.push({
308
+ name: "redirect_to_challenge",
309
+ weight: 0.7,
310
+ details: `Final URL is challenge domain: ${finalUrl}`,
311
+ source: "full",
312
+ });
313
+ break;
314
+ }
315
+ }
316
+ }
317
+ catch (err) {
318
+ this.logger.debug(`Detection check failed: ${err.message}`);
319
+ }
320
+ return { signals, chain };
321
+ }
322
+ async detectMetaRefresh(page) {
323
+ try {
324
+ const refreshUrl = await page.evaluate(() => {
325
+ const meta = document.querySelector('meta[http-equiv="refresh"]');
326
+ if (!meta)
327
+ return null;
328
+ const content = meta.getAttribute("content") ?? "";
329
+ const match = content.match(/url\s*=\s*(.+)/i);
330
+ return match ? match[1].trim() : null;
331
+ });
332
+ if (refreshUrl) {
333
+ for (const pattern of CHALLENGE_DOMAIN_PATTERNS) {
334
+ if (refreshUrl.includes(pattern)) {
335
+ return {
336
+ name: "meta_refresh_challenge",
337
+ weight: 0.65,
338
+ details: `Meta refresh to challenge URL: ${refreshUrl}`,
339
+ source: "full",
340
+ };
341
+ }
342
+ }
343
+ }
344
+ }
345
+ catch (err) {
346
+ this.logger.debug(`Detection check failed: ${err.message}`);
347
+ }
348
+ return null;
349
+ }
350
+ // --- Two-Pass Analysis API ---
351
+ /**
352
+ * Fast pass - runs at domcontentloaded. Only HTTP status + response headers.
353
+ * If score >= 0.9, caller should trigger remediation immediately.
354
+ */
355
+ async analyzeFast(page, response) {
356
+ const url = page.url();
357
+ const hostname = this.extractHostname(url);
358
+ if (!this.config.enabled) {
359
+ return this.makeResult(url, hostname, [], "fast", []);
360
+ }
361
+ const signals = [];
362
+ const statusSignal = this.detectHttpStatus(response);
363
+ if (statusSignal)
364
+ signals.push(statusSignal);
365
+ const headerSignals = this.detectResponseHeaders(response);
366
+ signals.push(...headerSignals);
367
+ const result = this.makeResult(url, hostname, signals, "fast", []);
368
+ this.logResult(result);
369
+ return result;
370
+ }
371
+ /**
372
+ * Run all content-based detectors in parallel.
373
+ * Shared by analyzeFull and analyzeSpa.
374
+ */
375
+ async runContentDetectors(page) {
376
+ const [titleSignal, challengeSignal, textSignals, ratioSignal, metaSignal] = await Promise.all([
377
+ this.detectTitleKeywords(page),
378
+ this.detectChallengeSelectors(page),
379
+ this.detectVisibleText(page, false),
380
+ this.detectTextToHtmlRatio(page),
381
+ this.detectMetaRefresh(page),
382
+ ]);
383
+ const signals = [];
384
+ if (titleSignal)
385
+ signals.push(titleSignal);
386
+ if (challengeSignal)
387
+ signals.push(challengeSignal);
388
+ signals.push(...textSignals);
389
+ if (ratioSignal)
390
+ signals.push(ratioSignal);
391
+ if (metaSignal)
392
+ signals.push(metaSignal);
393
+ return signals;
394
+ }
395
+ /**
396
+ * Full pass - runs after networkidle. Runs all detectors and merges with fast pass.
397
+ */
398
+ async analyzeFull(page, response, fastResult) {
399
+ const url = page.url();
400
+ const hostname = this.extractHostname(url);
401
+ if (!this.config.enabled) {
402
+ return this.makeResult(url, hostname, [], "full", []);
403
+ }
404
+ // Start with fast-pass signals
405
+ const signals = fastResult
406
+ ? [...fastResult.signals]
407
+ : [];
408
+ // If no fast pass was done and we have a response, run fast detectors
409
+ if (!fastResult && response) {
410
+ const statusSignal = this.detectHttpStatus(response);
411
+ if (statusSignal)
412
+ signals.push(statusSignal);
413
+ const headerSignals = this.detectResponseHeaders(response);
414
+ signals.push(...headerSignals);
415
+ }
416
+ signals.push(...await this.runContentDetectors(page));
417
+ const { signals: redirectSignals, chain } = this.detectRedirectChain(response);
418
+ signals.push(...redirectSignals);
419
+ return this.reEvaluateIfSuspected(page, url, hostname, signals, chain);
420
+ }
421
+ /**
422
+ * SPA navigation analysis - content-based detectors only, no HTTP signals.
423
+ */
424
+ async analyzeSpa(page) {
425
+ const url = page.url();
426
+ const hostname = this.extractHostname(url);
427
+ if (!this.config.enabled) {
428
+ return this.makeResult(url, hostname, [], "full", []);
429
+ }
430
+ const signals = await this.runContentDetectors(page);
431
+ return this.reEvaluateIfSuspected(page, url, hostname, signals, []);
432
+ }
433
+ async reEvaluateIfSuspected(page, url, hostname, signals, redirectChain) {
434
+ const preliminary = this.computeScore(signals);
435
+ if (preliminary.score >= 0.4 && preliminary.score < 0.7) {
436
+ const nonTextSignals = signals.filter((s) => !s.name.startsWith("visible_text_"));
437
+ const innerTextSignals = await this.detectVisibleText(page, true);
438
+ nonTextSignals.push(...innerTextSignals);
439
+ const result = this.makeResult(url, hostname, nonTextSignals, "full", redirectChain);
440
+ this.logResult(result);
441
+ return result;
442
+ }
443
+ const result = this.makeResult(url, hostname, signals, "full", redirectChain);
444
+ this.logResult(result);
445
+ return result;
446
+ }
447
+ // --- Utility Methods ---
448
+ makeResult(url, hostname, signals, pass, redirectChain) {
449
+ const { score, blockStatus } = this.computeScore(signals);
450
+ return {
451
+ url,
452
+ hostname,
453
+ blockStatus,
454
+ score,
455
+ signals,
456
+ pass,
457
+ persistentBlock: false,
458
+ redirectChain,
459
+ };
460
+ }
461
+ logResult(result) {
462
+ if (!this.logger.isDebug)
463
+ return;
464
+ this.logger.debug(`Detection result: ${JSON.stringify({
465
+ url: result.url,
466
+ blockStatus: result.blockStatus,
467
+ score: result.score,
468
+ signals: result.signals.map((s) => ({
469
+ name: s.name,
470
+ weight: s.weight,
471
+ source: s.source,
472
+ })),
473
+ pass: result.pass,
474
+ })}`);
475
+ }
476
+ extractHostname(url) {
477
+ try {
478
+ const parsed = new URL(url);
479
+ return parsed.hostname || url;
480
+ }
481
+ catch {
482
+ return url;
483
+ }
484
+ }
485
+ }
486
+ exports.BlockDetection = BlockDetection;
@@ -5,28 +5,27 @@ exports.ConfigManager = void 0;
5
5
  const logger_js_1 = require("./logger.js");
6
6
  const errors_js_1 = require("../errors.js");
7
7
  const request_js_1 = require("../api/request.js");
8
- function isRecord(value) {
9
- return typeof value === 'object' && value !== null;
10
- }
8
+ const apiUtils_js_1 = require("../api/apiUtils.js");
9
+ const rules_js_1 = require("./rules.js");
11
10
  function toAccountConnectionApiResponse(value) {
12
- if (!isRecord(value))
11
+ if (!(0, apiUtils_js_1.isRecord)(value))
13
12
  return {};
14
13
  const data = value['data'];
15
- if (!isRecord(data))
14
+ if (!(0, apiUtils_js_1.isRecord)(data))
16
15
  return {};
17
16
  return { data: data };
18
17
  }
19
18
  function toValidationErrors(value) {
20
- if (!isRecord(value))
19
+ if (!(0, apiUtils_js_1.isRecord)(value))
21
20
  return null;
22
21
  const apiError = value['error'];
23
- if (!isRecord(apiError))
22
+ if (!(0, apiUtils_js_1.isRecord)(apiError))
24
23
  return null;
25
24
  if (apiError['code'] !== 'validation_error')
26
25
  return null;
27
26
  const details = apiError['details'];
28
27
  const errors = [];
29
- if (isRecord(details)) {
28
+ if ((0, apiUtils_js_1.isRecord)(details)) {
30
29
  for (const fieldMessages of Object.values(details)) {
31
30
  if (Array.isArray(fieldMessages)) {
32
31
  for (const message of fieldMessages) {
@@ -48,6 +47,10 @@ function toValidationErrors(value) {
48
47
  * - Providing current config to ProxyServer
49
48
  */
50
49
  class ConfigManager {
50
+ /** Public read-only access to the account connection ID. */
51
+ get connectionId() {
52
+ return this.accountConnectionId;
53
+ }
51
54
  constructor(options) {
52
55
  this.config = null;
53
56
  this.timer = null;
@@ -65,7 +68,7 @@ class ConfigManager {
65
68
  */
66
69
  async init() {
67
70
  if (this.options.connectionId) {
68
- this.accountConnectionId = this.options.connectionId ?? null;
71
+ this.accountConnectionId = this.options.connectionId;
69
72
  this.logger.info(`Using account connection API (connection id: ${this.accountConnectionId})`);
70
73
  let result;
71
74
  try {
@@ -82,9 +85,7 @@ class ConfigManager {
82
85
  const msg = err instanceof Error ? err.message : String(err);
83
86
  throw new errors_js_1.ApiError(`Failed to fetch account connection config: ${msg}`);
84
87
  }
85
- if (result.status === 401 || result.status === 403) {
86
- throw new errors_js_1.InvalidApiKeyError(`Authentication failed with status ${result.status}`);
87
- }
88
+ (0, apiUtils_js_1.throwIfAuthError)(result.status);
88
89
  if (result.status === 200 && result.body) {
89
90
  this.config = this.buildConfigFromAny(result.body, result.etag);
90
91
  this.logger.info('Configuration loaded successfully');
@@ -103,12 +104,11 @@ class ConfigManager {
103
104
  path: '/account/connections',
104
105
  body: {},
105
106
  });
106
- if (created.status === 401 || created.status === 403) {
107
- throw new errors_js_1.InvalidApiKeyError(`Authentication failed with status ${created.status}`);
108
- }
107
+ (0, apiUtils_js_1.throwIfAuthError)(created.status);
109
108
  if ((created.status === 200 || created.status === 201) && created.body) {
110
109
  const createdResponse = toAccountConnectionApiResponse(created.body);
111
- this.accountConnectionId = Number(createdResponse.data?.connection_id);
110
+ const rawId = Number(createdResponse.data?.connection_id);
111
+ this.accountConnectionId = Number.isFinite(rawId) ? rawId : undefined;
112
112
  if (this.accountConnectionId != null) {
113
113
  this.logger.info(`Account connection created (connection id: ${this.accountConnectionId})`);
114
114
  }
@@ -168,6 +168,7 @@ class ConfigManager {
168
168
  this.pollInFlight = false;
169
169
  }
170
170
  }, this.options.pollIntervalMs);
171
+ this.timer.unref();
171
172
  }
172
173
  /**
173
174
  * Stop polling for configuration updates.
@@ -187,6 +188,9 @@ class ConfigManager {
187
188
  return this.config;
188
189
  }
189
190
  async setConfig(body) {
191
+ if (this.accountConnectionId == null || !Number.isFinite(this.accountConnectionId)) {
192
+ throw new errors_js_1.ApiError('Cannot update config: no account connection ID. Ensure init() succeeds first.');
193
+ }
190
194
  this.logger.debug(`Setting config: ${JSON.stringify(body)}`);
191
195
  let result;
192
196
  try {
@@ -204,9 +208,7 @@ class ConfigManager {
204
208
  const msg = err instanceof Error ? err.message : String(err);
205
209
  throw new errors_js_1.ApiError(`Failed to update account connection config: ${msg}`);
206
210
  }
207
- if (result.status === 401 || result.status === 403) {
208
- throw new errors_js_1.InvalidApiKeyError(`Authentication failed with status ${result.status}`);
209
- }
211
+ (0, apiUtils_js_1.throwIfAuthError)(result.status);
210
212
  if (result.status === 200 && result.body) {
211
213
  this.config = this.buildConfigFromAny(result.body, result.etag);
212
214
  this.logger.debug('Configuration updated from API');
@@ -226,8 +228,8 @@ class ConfigManager {
226
228
  * Called by the polling timer.
227
229
  */
228
230
  async pollOnce() {
229
- if (!this.config) {
230
- this.logger.warn('No config available, skipping poll');
231
+ if (!this.config || this.accountConnectionId == null) {
232
+ this.logger.warn('No config or connection ID available, skipping poll');
231
233
  return;
232
234
  }
233
235
  try {
@@ -263,8 +265,8 @@ class ConfigManager {
263
265
  const rules = Array.isArray(data?.rules) ? data.rules : [];
264
266
  const sessionId = data?.session_id ?? null;
265
267
  const targetGeo = data?.target_geo ?? null;
266
- const username = data?.proxy_username ?? null;
267
- const password = data?.proxy_password ?? null;
268
+ const username = (data?.proxy_username ?? '').trim() || null;
269
+ const password = (data?.proxy_password ?? '').trim() || null;
268
270
  if (!username || !password) {
269
271
  throw new errors_js_1.ApiError('Account connection response missing proxy credentials (data.proxy_username and data.proxy_password are required)', 500);
270
272
  }
@@ -277,6 +279,7 @@ class ConfigManager {
277
279
  password,
278
280
  },
279
281
  rules,
282
+ normalizedRules: (0, rules_js_1.normalizeRules)(rules),
280
283
  sessionId,
281
284
  targetGeo,
282
285
  etag,