ebay-mcp-remote-edition 4.6.0 → 4.7.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/README.md CHANGED
@@ -54,7 +54,7 @@ This is an open-source project provided "as is" without warranty of any kind. No
54
54
  | Mode | Command | Transport | Best for | Authorization model |
55
55
  |------|---------|-----------|----------|---------------------|
56
56
  | **Local STDIO** | `pnpm start` / `pnpm run dev` | stdin/stdout | Single-user local AI client (Claude Desktop, Cline, Cursor, etc.) | The local process reads eBay credentials and optional `EBAY_USER_REFRESH_TOKEN` from environment variables. |
57
- | **Hosted HTTP** | `pnpm run start:http` / `pnpm run dev:http` | Streamable HTTP | Multi-user server deployment; remote MCP clients | Users normally authorize through the browser OAuth flow and then call MCP with `Authorization: Bearer <session-token>`. |
57
+ | **Hosted HTTP** | `pnpm run start:http` / `pnpm run dev:http` | Streamable HTTP | Multi-user server deployment; remote MCP clients | Users authorize through browser OAuth. Requests can use normal session Bearer auth or opt into server-request auth with `X-Ebay-Server-Request: true`. |
58
58
 
59
59
  Both modes use the same eBay tool registry. Local STDIO is best when one trusted local client owns the eBay credentials. Hosted HTTP runs an Express server with OAuth 2.1 discovery, environment-scoped route trees, server-side token/session storage, and admin-only operational endpoints.
60
60
 
@@ -278,9 +278,16 @@ QSTASH_CURRENT_SIGNING_KEY=
278
278
  QSTASH_NEXT_SIGNING_KEY=
279
279
  EBAY_RESEARCH_SESSION_ALERTS_ENABLED=true
280
280
  EBAY_RESEARCH_SESSION_ALERT_CALLBACK_URL=
281
+
282
+ # eBay Research/Terapeak first-party session source.
283
+ # Set this explicitly in hosted deployments. If unset, research sessions default
284
+ # to Cloudflare KV even when EBAY_TOKEN_STORE_BACKEND=upstash-redis.
285
+ EBAY_RESEARCH_SESSION_STORE=upstash-redis # cloudflare_kv | upstash-redis | filesystem | none
286
+ EBAY_RESEARCH_SESSION_ALLOW_FILESYSTEM_FALLBACK=false
281
287
  ```
282
288
 
283
289
  > `EBAY_TOKEN_STORE_BACKEND` defaults to Cloudflare KV when unset or unrecognized. Use `memory` only for tests or throwaway local development because hosted sessions and tokens are lost on restart.
290
+ > `EBAY_RESEARCH_SESSION_STORE` is separate from OAuth token storage. Set it to `upstash-redis` when Terapeak/eBay Research cookies are stored in Upstash.
284
291
 
285
292
  ### Secret file
286
293
 
@@ -321,7 +328,15 @@ GET /production/oauth/start # production browser login
321
328
 
322
329
  If `OAUTH_START_KEY` is set, start URLs require either `?key=YOUR_KEY` or the `X-OAuth-Start-Key: YOUR_KEY` header. The server also includes this key as `key` in generated `authorization_url` values for unauthenticated MCP requests.
323
330
 
324
- After login, the callback page shows your **session token** with copy buttons.
331
+ After login, the callback page shows three hosted auth options with copy buttons:
332
+
333
+ | Hosted auth mode | How to select it | Best for |
334
+ |------------------|------------------|----------|
335
+ | **User/session mode** | Send `Authorization: Bearer <session-token>` and omit `X-Ebay-Server-Request` | Normal OAuth-aware MCP clients and user-scoped desktop clients. |
336
+ | **Server request mode — identity headers** | Send `X-Ebay-Server-Request: true`, `X-Ebay-Client-Id`, `X-Ebay-User-Id`, and optional `X-Ebay-Environment` | Server/client setups that need to handle both regular user requests and backend server requests without copying a session token. |
337
+ | **Server request mode — bearer-capable clients** | Send `X-Ebay-Server-Request: true` plus `Authorization: Bearer <mcp-server-issued-bearer-token>` | MCP clients or automation platforms that can store an authorization header but should not use the admin key. |
338
+
339
+ Switching between modes is per request, not a server-wide environment toggle. The same hosted MCP server can handle user/session requests and server requests concurrently; the client chooses server mode by adding `X-Ebay-Server-Request: true`.
325
340
 
326
341
  **Session TTL schedule:**
327
342
 
@@ -351,8 +366,12 @@ POST/GET/DELETE /mcp # resolves from ?env= or EBAY_ENVIRONMENT/EBAY_DEFAULT_EN
351
366
  - `GET /mcp` without token → redirects to `oauth/start`
352
367
  - `POST /mcp` without token → `401` JSON with `authorization_url`, `resource_metadata`, and a `WWW-Authenticate` Bearer challenge
353
368
  - Normal user requests: `Authorization: Bearer <session-token>`
369
+ - Server requests with identity headers: `X-Ebay-Server-Request: true`, `X-Ebay-Client-Id: <client-id>`, `X-Ebay-User-Id: <user-id>`, `X-Ebay-Environment: sandbox|production`
370
+ - Server requests with bearer-capable clients: `X-Ebay-Server-Request: true` plus `Authorization: Bearer <mcp-server-issued-bearer-token>`
354
371
  - Privileged admin bypass: `Authorization: Bearer <ADMIN_API_KEY>` when `ADMIN_API_KEY` is configured
355
372
 
373
+ The `X-Ebay-Server-Request` header is intentionally client-side and per-request. Leave it off for normal user/session OAuth calls. Add it when the MCP client is making a server-style request and should resolve the stored Redis/KV user token record by headers or by the MCP server-issued bearer lookup token. This bearer lookup token is generated by this MCP server on the OAuth callback page; it is **not** an eBay user access token, eBay refresh token, eBay client-credentials/app token, legacy hosted session token, or `ADMIN_API_KEY`.
374
+
356
375
  #### Admin key bypass
357
376
 
358
377
  `ADMIN_API_KEY` is used in two distinct ways:
@@ -380,6 +399,31 @@ POST /admin/session/:sessionToken/revoke # revoke session
380
399
  DELETE /admin/session/:sessionToken # delete session
381
400
  ```
382
401
 
402
+ ### Admin endpoints
403
+
404
+ All `/admin/*` routes accept authentication via either:
405
+ - Header: `X-Admin-API-Key: <ADMIN_API_KEY>`
406
+ - Query param: `?key=<ADMIN_API_KEY>` (useful for browser access)
407
+
408
+ ```
409
+ GET /admin/token-status # OAuth + Playwright session health
410
+ POST /admin/oauth/start-for-validation # Start OAuth flow for validation runner
411
+ POST /admin/playwright-session # Store Playwright storage state JSON
412
+ GET /admin/playwright-capture # Browser UI to capture eBay Research cookies
413
+ ```
414
+
415
+ **`/admin/playwright-capture`** renders a self-service page for renewing the eBay Research Playwright session. Open it in a browser with the admin key as a query parameter:
416
+
417
+ ```
418
+ https://your-server.com/admin/playwright-capture?key=YOUR_ADMIN_API_KEY
419
+ ```
420
+
421
+ The page has two tabs:
422
+ 1. **Auto-Capture** — loads eBay Research in an iframe (may be blocked by eBay's security policies)
423
+ 2. **Manual Export** — provides step-by-step instructions to export cookies from Chrome DevTools, a bookmarklet for one-click cookie copying, and a text area to paste and submit the JSON
424
+
425
+ Submitted cookies are validated against the authenticated eBay Research ACTIVE endpoint before persistence, stored in the configured session backend (Upstash Redis / Cloudflare KV / filesystem), and written with a long KV TTL. Cookie expiry is tracked in metadata so the runtime can report missing/expired Terapeak auth separately from provider parsing failures.
426
+
383
427
  ### Validation endpoints
384
428
 
385
429
  ```
@@ -421,10 +465,48 @@ Cline auto-discovers OAuth, opens browser login, exchanges auth code, and stores
421
465
  }
422
466
  ```
423
467
 
468
+ **Server request mode (custom headers):**
469
+ 1. Open `https://your-server.com/sandbox/oauth/start` or `https://your-server.com/production/oauth/start`
470
+ 2. Complete eBay login
471
+ 3. Copy the server request headers shown on the callback page
472
+ 4. Configure the MCP client with those headers:
473
+ ```json
474
+ {
475
+ "mcpServers": {
476
+ "ebay-production-server": {
477
+ "url": "https://your-server.com/production/mcp",
478
+ "headers": {
479
+ "X-Ebay-Server-Request": "true",
480
+ "X-Ebay-Client-Id": "YOUR_EBAY_CLIENT_ID",
481
+ "X-Ebay-User-Id": "STORED_USER_ID_FROM_CALLBACK",
482
+ "X-Ebay-Environment": "production"
483
+ }
484
+ }
485
+ }
486
+ }
487
+ ```
488
+
489
+ **Server request mode (bearer-capable clients):**
490
+ Use this when the MCP client can set `Authorization` but cannot easily send several custom identity headers:
491
+ ```json
492
+ {
493
+ "mcpServers": {
494
+ "ebay-production-server": {
495
+ "url": "https://your-server.com/production/mcp",
496
+ "headers": {
497
+ "X-Ebay-Server-Request": "true",
498
+ "Authorization": "Bearer MCP_SERVER_ISSUED_BEARER_TOKEN_FROM_CALLBACK"
499
+ }
500
+ }
501
+ }
502
+ }
503
+ ```
504
+
424
505
  **Make / Zapier / other platforms:**
425
506
  1. Complete OAuth via browser at `/oauth/start`
426
- 2. Paste session token as API Key / Bearer token in connector settings
427
- 3. Set MCP URL to `https://your-server.com/sandbox/mcp`
507
+ 2. If the platform supports multiple headers, use server request mode identity headers
508
+ 3. If the platform only supports one auth header, use server request mode with the MCP server-issued bearer lookup token from the callback page
509
+ 4. Set MCP URL to `https://your-server.com/sandbox/mcp`
428
510
 
429
511
  ---
430
512
 
@@ -1,4 +1,4 @@
1
- import { randomUUID } from 'crypto';
1
+ import { createHash, randomUUID } from 'crypto';
2
2
  import { createKVStore } from '../auth/kv-store.js';
3
3
  // ── TTL constants (seconds) ────────────────────────────────────────────────
4
4
  /** 15 minutes — matches the eBay OAuth state parameter lifetime. */
@@ -24,6 +24,22 @@ const DEFAULT_REFRESH_TOKEN_TTL_S = 18 * 30 * 24 * 60 * 60;
24
24
  function secondsFromNow(ttlSeconds) {
25
25
  return new Date(Date.now() + ttlSeconds * 1_000).toISOString();
26
26
  }
27
+ function sha256(value) {
28
+ return createHash('sha256').update(value).digest('hex');
29
+ }
30
+ function getTokenTtlSeconds(tokenData) {
31
+ let ttlSeconds = DEFAULT_REFRESH_TOKEN_TTL_S;
32
+ if (tokenData.userRefreshTokenExpiry) {
33
+ const expiryMs = typeof tokenData.userRefreshTokenExpiry === 'number'
34
+ ? tokenData.userRefreshTokenExpiry
35
+ : new Date(tokenData.userRefreshTokenExpiry).getTime();
36
+ const remaining = Math.floor((expiryMs - Date.now()) / 1_000);
37
+ if (remaining > 0) {
38
+ ttlSeconds = remaining;
39
+ }
40
+ }
41
+ return ttlSeconds;
42
+ }
27
43
  export class MultiUserAuthStore {
28
44
  kv;
29
45
  /**
@@ -51,10 +67,16 @@ export class MultiUserAuthStore {
51
67
  userTokenKey(userId, environment) {
52
68
  return `user:${userId}:env:${environment}:tokens`;
53
69
  }
70
+ userTokenIndexKey(clientId, userId, environment) {
71
+ return `user_index:env:${environment}:client:${sha256(clientId)}:user:${userId}`;
72
+ }
73
+ serverBearerKey(token) {
74
+ return `server_bearer:${sha256(token)}`;
75
+ }
54
76
  sessionKey(sessionToken) {
55
77
  return `session:${sessionToken}`;
56
78
  }
57
- async createOAuthState(environment, returnTo, mcpContext) {
79
+ async createOAuthState(environment, returnTo, mcpContext, targetUserId) {
58
80
  const state = randomUUID();
59
81
  const record = {
60
82
  state,
@@ -63,6 +85,7 @@ export class MultiUserAuthStore {
63
85
  expiresAt: secondsFromNow(OAUTH_STATE_TTL_S),
64
86
  returnTo,
65
87
  ...mcpContext,
88
+ ...(targetUserId ? { targetUserId } : {}),
66
89
  };
67
90
  await this.kv.put(this.stateKey(state), record, OAUTH_STATE_TTL_S);
68
91
  return record;
@@ -83,28 +106,70 @@ export class MultiUserAuthStore {
83
106
  }
84
107
  async saveUserTokens(userId, environment, tokenData) {
85
108
  // Derive TTL from refresh token expiry when available; fall back to 18 months.
86
- let ttlSeconds = DEFAULT_REFRESH_TOKEN_TTL_S;
87
- if (tokenData.userRefreshTokenExpiry) {
88
- const expiryMs = typeof tokenData.userRefreshTokenExpiry === 'number'
89
- ? tokenData.userRefreshTokenExpiry
90
- : new Date(tokenData.userRefreshTokenExpiry).getTime();
91
- const remaining = Math.floor((expiryMs - Date.now()) / 1_000);
92
- if (remaining > 0) {
93
- ttlSeconds = remaining;
94
- }
95
- }
109
+ const ttlSeconds = getTokenTtlSeconds(tokenData);
110
+ const now = new Date().toISOString();
96
111
  const record = {
97
112
  userId,
98
113
  environment,
99
114
  tokenData,
100
- updatedAt: new Date().toISOString(),
115
+ updatedAt: now,
101
116
  expiresAt: secondsFromNow(ttlSeconds),
102
117
  };
103
118
  await this.kv.put(this.userTokenKey(userId, environment), record, ttlSeconds);
119
+ if (tokenData.clientId) {
120
+ const indexRecord = {
121
+ userId,
122
+ environment,
123
+ clientIdHash: sha256(tokenData.clientId),
124
+ createdAt: now,
125
+ updatedAt: now,
126
+ expiresAt: record.expiresAt,
127
+ };
128
+ await this.kv.put(this.userTokenIndexKey(tokenData.clientId, userId, environment), indexRecord, ttlSeconds);
129
+ }
104
130
  }
105
131
  async getUserTokens(userId, environment) {
106
132
  return await this.kv.get(this.userTokenKey(userId, environment));
107
133
  }
134
+ async getUserTokensByClientUser(clientId, userId, environment) {
135
+ const index = await this.kv.get(this.userTokenIndexKey(clientId, userId, environment));
136
+ if (index?.userId !== userId || index.environment !== environment) {
137
+ return null;
138
+ }
139
+ const record = await this.getUserTokens(index.userId, index.environment);
140
+ if (record?.tokenData.clientId !== clientId) {
141
+ return null;
142
+ }
143
+ return record;
144
+ }
145
+ async createServerBearerToken(userId, environment) {
146
+ const record = await this.getUserTokens(userId, environment);
147
+ if (!record?.tokenData) {
148
+ throw new Error(`Cannot create server bearer token without stored user tokens for ${userId}`);
149
+ }
150
+ const ttlSeconds = getTokenTtlSeconds(record.tokenData);
151
+ const token = `ebay_mcp_${randomUUID()}${randomUUID()}`;
152
+ const tokenHash = sha256(token);
153
+ const bearerRecord = {
154
+ tokenHash,
155
+ userId,
156
+ environment,
157
+ createdAt: new Date().toISOString(),
158
+ expiresAt: secondsFromNow(ttlSeconds),
159
+ };
160
+ await this.kv.put(this.serverBearerKey(token), bearerRecord, ttlSeconds);
161
+ return {
162
+ ...bearerRecord,
163
+ token,
164
+ };
165
+ }
166
+ async getUserTokensByServerBearerToken(token) {
167
+ const bearerRecord = await this.kv.get(this.serverBearerKey(token));
168
+ if (!bearerRecord) {
169
+ return null;
170
+ }
171
+ return await this.getUserTokens(bearerRecord.userId, bearerRecord.environment);
172
+ }
108
173
  async createSession(userId, environment) {
109
174
  const sessionToken = randomUUID() + randomUUID();
110
175
  const now = new Date().toISOString();
@@ -16,6 +16,18 @@ export class EbayOAuthClient {
16
16
  getTokenEndpoint() {
17
17
  return `${getOAuthTokenBaseUrl(this.config.environment)}/identity/v1/oauth2/token`;
18
18
  }
19
+ getEffectiveClientCredentials() {
20
+ return {
21
+ clientId: this.userTokens?.clientId || this.config.clientId,
22
+ clientSecret: this.userTokens?.clientSecret || this.config.clientSecret,
23
+ };
24
+ }
25
+ getEffectiveRedirectUri() {
26
+ return (this.userTokens?.ruName ||
27
+ this.userTokens?.redirectUri ||
28
+ this.config.ruName ||
29
+ this.config.redirectUri);
30
+ }
19
31
  async initialize() {
20
32
  if (this.context?.userId && this.context.environment) {
21
33
  const stored = await this.authStore.getUserTokens(this.context.userId, this.context.environment);
@@ -115,7 +127,8 @@ export class EbayOAuthClient {
115
127
  return this.appAccessToken;
116
128
  }
117
129
  const authUrl = this.getTokenEndpoint();
118
- const credentials = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64');
130
+ const effectiveCredentials = this.getEffectiveClientCredentials();
131
+ const credentials = Buffer.from(`${effectiveCredentials.clientId}:${effectiveCredentials.clientSecret}`).toString('base64');
119
132
  const response = await axios.post(authUrl, new URLSearchParams({
120
133
  grant_type: 'client_credentials',
121
134
  scope: 'https://api.ebay.com/oauth/api_scope',
@@ -130,12 +143,13 @@ export class EbayOAuthClient {
130
143
  return this.appAccessToken;
131
144
  }
132
145
  async exchangeCodeForToken(code) {
133
- const redirectUriForExchange = this.config.ruName || this.config.redirectUri;
146
+ const redirectUriForExchange = this.getEffectiveRedirectUri();
134
147
  if (!redirectUriForExchange) {
135
148
  throw new Error('RuName (EBAY_RUNAME) or redirectUri is required for authorization code exchange');
136
149
  }
137
150
  const tokenUrl = this.getTokenEndpoint();
138
- const credentials = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64');
151
+ const effectiveCredentials = this.getEffectiveClientCredentials();
152
+ const credentials = Buffer.from(`${effectiveCredentials.clientId}:${effectiveCredentials.clientSecret}`).toString('base64');
139
153
  try {
140
154
  const response = await axios.post(tokenUrl, new URLSearchParams({
141
155
  grant_type: 'authorization_code',
@@ -150,10 +164,10 @@ export class EbayOAuthClient {
150
164
  const tokenData = response.data;
151
165
  const now = Date.now();
152
166
  this.userTokens = {
153
- clientId: this.config.clientId,
154
- clientSecret: this.config.clientSecret,
167
+ clientId: effectiveCredentials.clientId,
168
+ clientSecret: effectiveCredentials.clientSecret,
155
169
  redirectUri: this.config.redirectUri,
156
- ruName: this.config.ruName,
170
+ ruName: redirectUriForExchange,
157
171
  userAccessToken: tokenData.access_token,
158
172
  userRefreshToken: tokenData.refresh_token,
159
173
  tokenType: tokenData.token_type,
@@ -183,7 +197,8 @@ export class EbayOAuthClient {
183
197
  throw new Error('No user tokens available to refresh');
184
198
  }
185
199
  const authUrl = this.getTokenEndpoint();
186
- const credentials = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64');
200
+ const effectiveCredentials = this.getEffectiveClientCredentials();
201
+ const credentials = Buffer.from(`${effectiveCredentials.clientId}:${effectiveCredentials.clientSecret}`).toString('base64');
187
202
  const response = await axios.post(authUrl, new URLSearchParams({
188
203
  grant_type: 'refresh_token',
189
204
  refresh_token: this.userTokens.userRefreshToken,
@@ -196,10 +211,10 @@ export class EbayOAuthClient {
196
211
  const tokenData = response.data;
197
212
  const now = Date.now();
198
213
  this.userTokens = {
199
- clientId: this.config.clientId,
200
- clientSecret: this.config.clientSecret,
201
- redirectUri: this.config.redirectUri,
202
- ruName: this.config.ruName,
214
+ clientId: effectiveCredentials.clientId,
215
+ clientSecret: effectiveCredentials.clientSecret,
216
+ redirectUri: this.userTokens.redirectUri || this.config.redirectUri,
217
+ ruName: this.userTokens.ruName || this.config.ruName,
203
218
  userAccessToken: tokenData.access_token,
204
219
  userRefreshToken: tokenData.refresh_token || this.userTokens.userRefreshToken,
205
220
  tokenType: tokenData.token_type,
@@ -0,0 +1,302 @@
1
+ import { createLogger } from '../utils/logger.js';
2
+ const logger = createLogger('captcha');
3
+ function makeCaptchaError(provider, message, code) {
4
+ const error = new Error(message);
5
+ error.provider = provider;
6
+ error.code = code;
7
+ return error;
8
+ }
9
+ // ---------------------------------------------------------------------------
10
+ // 2Captcha client
11
+ // ---------------------------------------------------------------------------
12
+ const TWOCAPTCHA_API_URL = 'https://2captcha.com';
13
+ class TwoCaptchaClient {
14
+ name = 'twocaptcha';
15
+ apiKey;
16
+ constructor(apiKey) {
17
+ this.apiKey = apiKey;
18
+ }
19
+ captchaTypeToMethod(type) {
20
+ switch (type) {
21
+ case 'hcaptcha':
22
+ return 'hcaptcha';
23
+ case 'recaptcha_v2':
24
+ return 'usercaptcha';
25
+ case 'recaptcha_v3':
26
+ return 'userrecaptcha';
27
+ }
28
+ }
29
+ buildFormData(payload) {
30
+ const params = new URLSearchParams();
31
+ for (const [key, value] of Object.entries(payload)) {
32
+ if (value !== undefined) {
33
+ params.append(key, value);
34
+ }
35
+ }
36
+ return params;
37
+ }
38
+ async createTask(config) {
39
+ const method = this.captchaTypeToMethod(config.type);
40
+ const payload = {
41
+ key: this.apiKey,
42
+ method,
43
+ sitekey: config.siteKey,
44
+ pageurl: config.pageUrl,
45
+ proxy: config.proxy,
46
+ proxytype: config.proxy ? 'http' : undefined,
47
+ json: '1', // Force JSON response format
48
+ };
49
+ const formData = this.buildFormData(payload);
50
+ const response = await fetch(`${TWOCAPTCHA_API_URL}/in.php`, {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
53
+ body: formData,
54
+ });
55
+ // 2Captcha may return either JSON or plaintext OK|taskId format
56
+ const text = await response.text();
57
+ let data;
58
+ if (text.startsWith('{')) {
59
+ data = JSON.parse(text);
60
+ }
61
+ else {
62
+ // Parse plaintext OK|taskId or ERROR|message
63
+ const parts = text.split('|');
64
+ if (parts[0] === 'OK' && parts[1]) {
65
+ return { taskId: parts[1] };
66
+ }
67
+ data = { error_text: parts[1] ?? text, error_id: -1 };
68
+ }
69
+ if (data.status !== 1 || !data.request) {
70
+ const errorMessage = data.error_text ?? `2Captcha error_id: ${data.error_id}`;
71
+ throw makeCaptchaError(this.name, `2Captcha createTask failed: ${errorMessage}`, String(data.error_id));
72
+ }
73
+ return { taskId: data.request };
74
+ }
75
+ async getResult(taskId) {
76
+ const payload = {
77
+ key: this.apiKey,
78
+ action: 'get',
79
+ json: '1', // Force JSON response format
80
+ id: taskId,
81
+ };
82
+ const formData = this.buildFormData(payload);
83
+ const response = await fetch(`${TWOCAPTCHA_API_URL}/res.php`, {
84
+ method: 'POST',
85
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
86
+ body: formData,
87
+ });
88
+ // Handle both JSON and plaintext formats
89
+ const text = await response.text();
90
+ let data;
91
+ if (text.startsWith('{')) {
92
+ data = JSON.parse(text);
93
+ }
94
+ else {
95
+ // Parse plaintext OK|solution or CAPCHA_NOT_READY
96
+ const parts = text.split('|');
97
+ if (parts[0] === 'OK' && parts[1]) {
98
+ return { token: parts[1], provider: this.name };
99
+ }
100
+ // CAPCHA_NOT_READY or other error
101
+ data = { error_text: parts[0] ?? text, error_id: 1 };
102
+ }
103
+ if (data.error_text && data.error_id !== 0) {
104
+ throw new Error(`2Captcha getResult error: ${data.error_text}`);
105
+ }
106
+ if (data.status === 1 && data.request) {
107
+ return { token: data.request, provider: this.name };
108
+ }
109
+ // Still processing (CAPCHA_NOT_READY)
110
+ return null;
111
+ }
112
+ }
113
+ // ---------------------------------------------------------------------------
114
+ // Solver factory
115
+ // ---------------------------------------------------------------------------
116
+ function resolveProvider() {
117
+ const twoCaptchaKey = process.env.TWOCAPTCHA_API_KEY;
118
+ if (twoCaptchaKey) {
119
+ logger.info('2Captcha client initialized');
120
+ return new TwoCaptchaClient(twoCaptchaKey);
121
+ }
122
+ return null;
123
+ }
124
+ // ---------------------------------------------------------------------------
125
+ // Public API
126
+ // ---------------------------------------------------------------------------
127
+ const DEFAULT_POLL_INTERVAL_MS = 3_000;
128
+ const DEFAULT_MAX_POLL_MS = 60_000;
129
+ export async function solveCaptcha(config, options = {}) {
130
+ const provider = resolveProvider();
131
+ if (!provider) {
132
+ throw new Error('No captcha solver configured. Set TWOCAPTCHA_API_KEY environment variable.');
133
+ }
134
+ const pollInterval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
135
+ const maxWait = options.maxWaitMs ?? DEFAULT_MAX_POLL_MS;
136
+ logger.info(`Attempting captcha solve via ${provider.name} (type=${config.type})`);
137
+ try {
138
+ const { taskId } = await provider.createTask(config);
139
+ logger.debug(`Task created: ${taskId} on ${provider.name}`);
140
+ const deadline = Date.now() + maxWait;
141
+ while (Date.now() < deadline) {
142
+ await new Promise((resolve) => {
143
+ setTimeout(() => resolve(), pollInterval);
144
+ });
145
+ const solution = await provider.getResult(taskId);
146
+ if (solution) {
147
+ logger.info(`Captcha solved via ${provider.name}`);
148
+ return solution;
149
+ }
150
+ }
151
+ const timeoutError = makeCaptchaError(provider.name, `${provider.name} timed out after ${maxWait}ms`);
152
+ throw timeoutError;
153
+ }
154
+ catch (err) {
155
+ let providerError;
156
+ if (err instanceof Error && 'provider' in err) {
157
+ providerError = err;
158
+ }
159
+ else {
160
+ providerError = makeCaptchaError(provider.name, err instanceof Error ? err.message : String(err));
161
+ }
162
+ logger.warn(`${provider.name} failed: ${err instanceof Error ? err.message : String(err)}`);
163
+ throw providerError;
164
+ }
165
+ }
166
+ /**
167
+ * Inject the captcha solution token into a Playwright page.
168
+ */
169
+ export async function injectCaptchaToken(page, type, token) {
170
+ if (type === 'hcaptcha') {
171
+ await page.evaluate((t) => {
172
+ const textarea = document.querySelector('textarea[name="h-captcha-response"]');
173
+ if (textarea) {
174
+ textarea.value = t;
175
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
176
+ }
177
+ }, token);
178
+ }
179
+ else {
180
+ await page.evaluate((t) => {
181
+ const iframe = document.querySelector('iframe[title="reCAPTCHA"], iframe[src*="recaptcha"]');
182
+ if (iframe) {
183
+ const iframeDoc = iframe.contentDocument;
184
+ if (iframeDoc) {
185
+ const textarea = iframeDoc.querySelector('textarea[name="g-recaptcha-response"]');
186
+ if (textarea) {
187
+ textarea.value = t;
188
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
189
+ }
190
+ }
191
+ }
192
+ }, token);
193
+ }
194
+ }
195
+ /**
196
+ * Check if a captcha challenge is present on a Playwright page.
197
+ */
198
+ export async function detectCaptcha(page) {
199
+ return await page.evaluate(() => {
200
+ const hcaptchaIframe = document.querySelector('iframe[src*="hcaptcha.com"]');
201
+ if (hcaptchaIframe)
202
+ return 'hcaptcha';
203
+ const hcaptchaWidget = document.querySelector('.hcaptcha');
204
+ if (hcaptchaWidget)
205
+ return 'hcaptcha';
206
+ const recaptchaIframe = document.querySelector('iframe[src*="recaptcha.net"], iframe[src*="recaptcha"]');
207
+ if (recaptchaIframe) {
208
+ const src = recaptchaIframe.src || '';
209
+ if (src.includes('render=explicit'))
210
+ return 'recaptcha_v3';
211
+ return 'recaptcha_v2';
212
+ }
213
+ const recaptchaWidget = document.querySelector('.g-recaptcha');
214
+ if (recaptchaWidget)
215
+ return 'recaptcha_v2';
216
+ return null;
217
+ });
218
+ }
219
+ /**
220
+ * Extract the captcha site key from the page.
221
+ */
222
+ export async function extractSiteKey(page, type) {
223
+ return await page.evaluate((ct) => {
224
+ const captchaType = ct;
225
+ if (captchaType === 'hcaptcha') {
226
+ const hcaptchaEl = document.querySelector('.hcaptcha[data-sitekey]');
227
+ if (hcaptchaEl) {
228
+ return hcaptchaEl.getAttribute('data-sitekey');
229
+ }
230
+ const hcaptchaIframe = document.querySelector('iframe[src*="hcaptcha.com"]');
231
+ if (hcaptchaIframe) {
232
+ const src = hcaptchaIframe.src || '';
233
+ const match = /sitekey=([^&]+)/.exec(src);
234
+ if (match)
235
+ return match[1];
236
+ }
237
+ const scripts = document.querySelectorAll('script');
238
+ for (const script of scripts) {
239
+ const text = script.textContent || '';
240
+ const match = /HCaptcha\.render\([^,]+,\s*['"]([^'"]+)['"]/.exec(text);
241
+ if (match)
242
+ return match[1];
243
+ }
244
+ return null;
245
+ }
246
+ else {
247
+ const recaptchaEl = document.querySelector('.g-recaptcha[data-sitekey]');
248
+ if (recaptchaEl) {
249
+ return recaptchaEl.getAttribute('data-sitekey');
250
+ }
251
+ const recaptchaIframe = document.querySelector('iframe[src*="recaptcha"], iframe[src*="recaptcha.net"]');
252
+ if (recaptchaIframe) {
253
+ const src = recaptchaIframe.src || '';
254
+ const match = /k=([^&]+)/.exec(src);
255
+ if (match)
256
+ return match[1];
257
+ }
258
+ const scripts = document.querySelectorAll('script');
259
+ for (const script of scripts) {
260
+ const text = script.textContent || '';
261
+ const match = /grecaptcha\.render\([^,]+,\s*['"]([^'"]+)['"]/.exec(text);
262
+ if (match)
263
+ return match[1];
264
+ }
265
+ return null;
266
+ }
267
+ }, type);
268
+ }
269
+ /**
270
+ * Helper: wait for captcha to appear on page, then solve and inject.
271
+ */
272
+ export async function waitForAndSolveCaptcha(page, options) {
273
+ const maxWait = options.maxWaitMs ?? 30_000;
274
+ const checkInterval = options.checkIntervalMs ?? 1_000;
275
+ const deadline = Date.now() + maxWait;
276
+ while (Date.now() < deadline) {
277
+ const captchaType = await detectCaptcha(page);
278
+ if (captchaType) {
279
+ logger.info(`Detected ${captchaType} on ${options.pageUrl}`);
280
+ const siteKey = await extractSiteKey(page, captchaType);
281
+ if (!siteKey) {
282
+ logger.error('Captcha detected but site key could not be extracted');
283
+ throw new Error('Could not extract captcha site key from page');
284
+ }
285
+ logger.debug(`Extracted site key: ${siteKey}`);
286
+ const solution = await solveCaptcha({
287
+ type: captchaType,
288
+ siteKey,
289
+ pageUrl: options.pageUrl,
290
+ proxy: options.proxy,
291
+ });
292
+ await injectCaptchaToken(page, captchaType, solution.token);
293
+ logger.info(`Injected ${captchaType} solution token`);
294
+ return solution;
295
+ }
296
+ await new Promise((resolve) => {
297
+ setTimeout(() => resolve(), checkInterval);
298
+ });
299
+ }
300
+ logger.debug('No captcha detected within timeout');
301
+ return null;
302
+ }
@@ -166,7 +166,7 @@ export function ruNameToEnvironment(ruName) {
166
166
  */
167
167
  export function validateCredentialsForEnvironment(environment) {
168
168
  const config = getEbayConfig(environment);
169
- const ruName = config.redirectUri;
169
+ const ruName = config.ruName || config.redirectUri;
170
170
  const detectedEnv = ruNameToEnvironment(ruName);
171
171
  if (detectedEnv === null) {
172
172
  // Can't tell from the RuName — treat as valid (no info to contradict it).