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