ebay-mcp-remote-edition 2.0.16 → 3.1.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
@@ -21,7 +21,7 @@ This fork of [Yosef Hayim's eBay MCP](https://github.com/YosefHayim/ebay-mcp) pr
21
21
 
22
22
  - Hosted Streamable HTTP MCP deployment mode for remote server deployment
23
23
  - Multi-user server-side eBay OAuth for both production and sandbox
24
- - Cloudflare KV-backed storage for:
24
+ - Cloudflare KV / Upstash Redis-backed storage for:
25
25
  - OAuth state
26
26
  - user token records
27
27
  - session records
@@ -32,6 +32,8 @@ This fork of [Yosef Hayim's eBay MCP](https://github.com/YosefHayim/ebay-mcp) pr
32
32
  - `GET /whoami` endpoint for session-bound identity lookup
33
33
  - Optional `OAUTH_START_KEY` protection for `/oauth/start`
34
34
  - **MCP OAuth 2.1 authorization server** — Cline and other MCP clients that support OAuth discovery (`/.well-known/oauth-authorization-server`, `POST /register`, `GET /authorize`, `POST /token`) can authenticate fully automatically via browser eBay OAuth with no manual token pasting
35
+ - **Environment-scoped route trees** — `/sandbox/mcp` and `/production/mcp` hard-bind their eBay environment; no `?env=` query param needed. Matching scoped discovery, register, authorize, token, and oauth/start endpoints included. Legacy `/mcp` kept for backward compatibility
36
+ - **TTL-aligned session and token records** — Every stored record (`OAuthState`, `AuthCode`, `Session`, `UserToken`) now includes an `expiresAt` ISO timestamp and passes the matching TTL to the KV/Redis backend so application expiry and Redis key expiry are always in sync. Session TTL defaults to 30 days and is configurable via `SESSION_TTL_SECONDS`
35
37
 
36
38
  ---
37
39
 
@@ -124,7 +126,32 @@ For official eBay API support, please refer to the [eBay Developer Program](http
124
126
  1. Create a free [eBay Developer Account](https://developer.ebay.com/)
125
127
  2. Generate application keys in the [Developer Portal](https://developer.ebay.com/my/keys)
126
128
  3. Save your **Client ID** and **Client Secret**
127
- 4. Configure environment-specific RuNames for production and sandbox as needed
129
+ 4. Configure a **RuName** (Redirect URL Name) for each environment you plan to use
130
+
131
+ > **Local HTTPS callback URL (required by eBay)**
132
+ >
133
+ > eBay requires an **HTTPS** callback URL. For local development, use [mkcert](https://github.com/FiloSottile/mkcert) to create a locally-trusted certificate so your dev machine can serve HTTPS.
134
+ >
135
+ > **One-time mkcert setup (macOS):**
136
+ > ```bash
137
+ > brew install mkcert nss # nss adds Firefox trust support
138
+ > mkcert -install # installs local CA into system trust store
139
+ > mkcert ebay-local.test # creates ebay-local.test.pem + ebay-local.test-key.pem
140
+ > echo "127.0.0.1 ebay-local.test" | sudo tee -a /etc/hosts
141
+ > ```
142
+ >
143
+ > In the eBay Developer Portal, register **`https://ebay-local.test:3000/oauth/callback`** as the callback URL under **User Tokens → Add RuName**. eBay will generate a RuName string (e.g. `YourApp-YourApp-SBX-abcdefg`). Copy that RuName into `EBAY_RUNAME` (or `EBAY_SANDBOX_RUNAME` / `EBAY_PRODUCTION_RUNAME`).
144
+ >
145
+ > Then add to your `.env`:
146
+ > ```
147
+ > PUBLIC_BASE_URL=https://ebay-local.test:3000
148
+ > EBAY_LOCAL_TLS_CERT_PATH=/path/to/ebay-local.test.pem
149
+ > EBAY_LOCAL_TLS_KEY_PATH=/path/to/ebay-local.test-key.pem
150
+ > ```
151
+ >
152
+ > The server automatically starts an HTTPS callback listener when `PUBLIC_BASE_URL` begins with `https://`.
153
+ >
154
+ > **Note:** `EBAY_RUNAME` is an eBay-generated string (the RuName), **not** the callback URL itself. The callback URL is set via `PUBLIC_BASE_URL`. If your eBay app was previously configured only with a hosted callback URL, you will need to add the local URL as an additional RuName in the portal — eBay currently only allows one OAuth-enabled RuName per app at a time.
128
155
 
129
156
  ### 2. Install
130
157
 
@@ -151,6 +178,30 @@ pnpm install
151
178
  pnpm run build
152
179
  ```
153
180
 
181
+ ### Environment management with dotenvx
182
+
183
+ `dotenvx` is for local env workflows only.
184
+
185
+ Hosted/server platforms should provide environment variables directly.
186
+ For local development, the standard runtime scripts automatically load `.env`
187
+ through dotenvx unless a hosted environment is detected.
188
+
189
+ Common commands:
190
+
191
+ ```bash
192
+ pnpm run env:encrypt
193
+ pnpm run env:decrypt
194
+ pnpm run env:run -- pnpm run dev:http
195
+ ```
196
+
197
+ This lets you keep a local `.env` for development while also supporting
198
+ encrypted env files for sharing or deployment workflows.
199
+
200
+ The built-in runtime scripts now behave like this:
201
+
202
+ - local machine → load `.env` via dotenvx automatically
203
+ - hosted platform (for example `RENDER=true`) → use platform-provided env vars directly
204
+
154
205
  ### 3. Run Local Setup Wizard
155
206
 
156
207
  For local/STDIO usage after cloning:
@@ -176,6 +227,8 @@ The configs below show both. Supply your eBay credentials either as `env` fields
176
227
 
177
228
  > **Getting your credentials:** Run `pnpm run setup` in the cloned repo — it completes the OAuth flow and writes `EBAY_USER_REFRESH_TOKEN` to `.env`.
178
229
 
230
+ > **`EBAY_RUNAME` is a RuName string, not a URL.** It looks like `YourApp-YourApp-SBX-abcdefg`. To obtain one, register your HTTPS callback URL in the [eBay Developer Portal](https://developer.ebay.com/my/auth) under **User Tokens → Add RuName**, then copy the generated string. See [Step 1](#1-get-ebay-credentials) for the full setup guide including mkcert local HTTPS. `EBAY_REDIRECT_URI` is still accepted as a legacy alias for `EBAY_RUNAME`.
231
+
179
232
  ### Cline
180
233
 
181
234
  Config file location:
@@ -352,20 +405,26 @@ pnpm install && pnpm run build
352
405
  pnpm run start:http
353
406
  ```
354
407
 
408
+ On hosted platforms, this uses the platform env directly and does not try to load local `.env` files.
409
+
355
410
  ### Recommended Render environment variables
356
411
 
357
412
  ```bash
358
413
  PORT=
414
+ MCP_HOST=0.0.0.0
359
415
  NODE_VERSION=
360
416
  PUBLIC_BASE_URL=https://your-server.com
361
417
  EBAY_CONFIG_FILE=/etc/secrets/ebay-config.json
362
418
  EBAY_DEFAULT_ENVIRONMENT=sandbox|production
363
- EBAY_TOKEN_STORE_BACKEND=cloudflare-kv
419
+ EBAY_TOKEN_STORE_BACKEND=cloudflare-kv|upstash-redis
364
420
  CLOUDFLARE_ACCOUNT_ID=ID
365
421
  CLOUDFLARE_KV_NAMESPACE_ID=ID
366
422
  CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
423
+ UPSTASH_REDIS_REST_URL=https://...upstash.io
424
+ UPSTASH_REDIS_REST_TOKEN=your-upstash-rest-token
367
425
  ADMIN_API_KEY=your-admin-api-key
368
426
  OAUTH_START_KEY=optional-shared-secret-for-oauth-start
427
+ SESSION_TTL_SECONDS=2592000 # optional, default 30 days (2592000 s)
369
428
  EBAY_MARKETPLACE_ID=EBAY_COUNTRY
370
429
  EBAY_CONTENT_LANGUAGE=lang-COUNTRY
371
430
  EBAY_LOG_LEVEL=info
@@ -404,22 +463,25 @@ Render mounts it at:
404
463
 
405
464
  ### OAuth flows
406
465
 
407
- Start production OAuth:
466
+ **Preferred environment-scoped paths (no `?env=` needed):**
408
467
 
409
468
  ```text
410
- /oauth/start?env=production
469
+ /sandbox/oauth/start # always sandbox
470
+ /production/oauth/start # always production
411
471
  ```
412
472
 
413
- Start sandbox OAuth:
473
+ **Legacy `?env=` query param (also still supported):**
414
474
 
415
475
  ```text
416
476
  /oauth/start?env=sandbox
477
+ /oauth/start?env=production
417
478
  ```
418
479
 
419
- If `OAUTH_START_KEY` is configured, include either:
480
+ If `OAUTH_START_KEY` is configured, include it as a query param or header:
420
481
 
421
482
  ```text
422
- /oauth/start?env=production&key=YOUR_OAUTH_START_KEY
483
+ /sandbox/oauth/start?key=YOUR_OAUTH_START_KEY
484
+ /production/oauth/start?key=YOUR_OAUTH_START_KEY
423
485
  ```
424
486
 
425
487
  or send:
@@ -430,7 +492,7 @@ X-OAuth-Start-Key: YOUR_OAUTH_START_KEY
430
492
 
431
493
  ### Hosted session token usage
432
494
 
433
- After successful callback, the app issues a session token and displays it in a copy-friendly callback page.
495
+ After successful callback, the app issues a session token and stores it in the configured persistent backend (Cloudflare KV or Upstash Redis), then displays it in a copy-friendly callback page.
434
496
 
435
497
  Use it in your MCP client:
436
498
 
@@ -444,7 +506,7 @@ For Make/Zapier/TypingMind anywhere where Remote MCP is accepted, the practical
444
506
  2. copy the returned session token from the callback page
445
507
  3. paste it into the platform's API Key / Access token field
446
508
 
447
- Normal MCP usage should not open a browser window once a valid hosted session token already exists.
509
+ Normal MCP usage should not open a browser window once a valid hosted session token already exists in the configured persistent store.
448
510
 
449
511
  ### Admin endpoints
450
512
 
@@ -467,20 +529,28 @@ GET /whoami
467
529
  Authorization: Bearer <session-token>
468
530
  ```
469
531
 
470
- ### MCP endpoint
532
+ ### MCP endpoints
533
+
534
+ **Preferred — environment-scoped (hard-binds the eBay environment):**
535
+
536
+ ```text
537
+ POST/GET/DELETE /sandbox/mcp # always sandbox
538
+ POST/GET/DELETE /production/mcp # always production
539
+ ```
540
+
541
+ Each scoped path has its own `/.well-known/oauth-authorization-server` so MCP clients that support OAuth discovery (Cline, etc.) automatically use the correct environment.
542
+
543
+ **Legacy — auto-resolves environment from `?env=` or `EBAY_ENVIRONMENT`:**
471
544
 
472
545
  ```text
473
- POST /mcp
474
- GET /mcp
475
- DELETE /mcp
546
+ POST/GET/DELETE /mcp
476
547
  ```
477
548
 
478
- #### Initial auth behavior
549
+ #### Auth behavior (all paths)
479
550
 
480
- - `GET /mcp` without a valid Bearer token redirects into browser OAuth
481
- - default environment is production
482
- - sandbox can be requested with `?env=sandbox`
483
- - `POST /mcp` without a valid Bearer token returns an auth-required JSON response
551
+ - `GET /mcp` (or scoped variant) without a valid Bearer token redirects into browser OAuth via the matching `oauth/start` path
552
+ - `POST /mcp` without a valid Bearer token returns a structured `401` JSON response with an `authorization_url` field
553
+ - Scoped paths return their hard-bound environment in every error response so the client can pre-fill the correct OAuth start URL
484
554
 
485
555
  This means browser-driven onboarding works cleanly, while protocol clients can still receive a structured auth response.
486
556
 
@@ -504,37 +574,70 @@ Replace `https://your-server.com` with your actual `PUBLIC_BASE_URL`.
504
574
 
505
575
  ### Cline (recommended — full OAuth auto-discovery)
506
576
 
507
- Cline supports OAuth 2.1 discovery natively. Just point it at the MCP endpoint and it handles everything:
577
+ Cline supports OAuth 2.1 discovery natively. Point it at an environment-scoped MCP endpoint and it handles everything automatically — including fetching the correct discovery document, registering itself, opening the eBay browser login, and storing the session token.
578
+
579
+ **Sandbox:**
508
580
 
509
581
  ```json
510
582
  {
511
583
  "mcpServers": {
512
- "ebay": {
513
- "url": "https://your-server.com/mcp"
584
+ "ebay-sandbox": {
585
+ "url": "https://your-server.com/sandbox/mcp"
586
+ }
587
+ }
588
+ }
589
+ ```
590
+
591
+ **Production:**
592
+
593
+ ```json
594
+ {
595
+ "mcpServers": {
596
+ "ebay-production": {
597
+ "url": "https://your-server.com/production/mcp"
514
598
  }
515
599
  }
516
600
  }
517
601
  ```
518
602
 
519
603
  **What happens automatically:**
520
- 1. Cline fetches `/.well-known/oauth-authorization-server` to discover the auth server.
521
- 2. It registers itself at `POST /register` (Dynamic Client Registration).
522
- 3. Your browser opens `GET /authorize`, which redirects to eBay's login page.
604
+ 1. Cline fetches `/sandbox/.well-known/oauth-authorization-server` (or `/production/...`) to discover the scoped auth server.
605
+ 2. It registers itself at `POST /sandbox/register`.
606
+ 3. Your browser opens `GET /sandbox/authorize`, which redirects to eBay's sandbox login page.
523
607
  4. After you grant access, eBay redirects to `/oauth/callback`, which issues an MCP auth code and sends it back to Cline.
524
- 5. Cline exchanges the code at `POST /token` for a session token and stores it.
525
- 6. All subsequent `/mcp` requests are authenticated automatically.
608
+ 5. Cline exchanges the code at `POST /sandbox/token` for a session token and stores it.
609
+ 6. All subsequent `/sandbox/mcp` requests are authenticated automatically.
610
+
611
+ > **Legacy `?env=` URL** — If you prefer a single endpoint, `https://your-server.com/mcp` still works with environment auto-detection, but the env-scoped paths are recommended for unambiguous behaviour.
526
612
 
527
613
  > **`OAUTH_START_KEY` note:** If your server has `OAUTH_START_KEY` set, the `/authorize` endpoint also requires it. You can temporarily disable it for first-time client setup, or consult your server operator for the key.
528
614
 
529
615
  ### Claude Desktop (HTTP remote with pre-obtained session token)
530
616
 
531
- Claude Desktop's remote MCP support requires an explicit `Authorization` header. Complete the browser OAuth flow at `https://your-server.com/oauth/start` first to get your session token, then configure:
617
+ Claude Desktop's remote MCP support requires an explicit `Authorization` header. Complete the browser OAuth flow at the appropriate env-scoped start URL first, then configure:
618
+
619
+ **Sandbox:**
532
620
 
533
621
  ```json
534
622
  {
535
623
  "mcpServers": {
536
- "ebay": {
537
- "url": "https://your-server.com/mcp",
624
+ "ebay-sandbox": {
625
+ "url": "https://your-server.com/sandbox/mcp",
626
+ "headers": {
627
+ "Authorization": "Bearer YOUR_SESSION_TOKEN"
628
+ }
629
+ }
630
+ }
631
+ }
632
+ ```
633
+
634
+ **Production:**
635
+
636
+ ```json
637
+ {
638
+ "mcpServers": {
639
+ "ebay-production": {
640
+ "url": "https://your-server.com/production/mcp",
538
641
  "headers": {
539
642
  "Authorization": "Bearer YOUR_SESSION_TOKEN"
540
643
  }
@@ -548,8 +651,8 @@ Claude Desktop's remote MCP support requires an explicit `Authorization` header.
548
651
  ```json
549
652
  {
550
653
  "mcpServers": {
551
- "ebay": {
552
- "url": "https://your-server.com/mcp",
654
+ "ebay-sandbox": {
655
+ "url": "https://your-server.com/sandbox/mcp",
553
656
  "headers": {
554
657
  "Authorization": "Bearer YOUR_SESSION_TOKEN"
555
658
  }
@@ -562,11 +665,11 @@ Claude Desktop's remote MCP support requires an explicit `Authorization` header.
562
665
 
563
666
  These platforms use a fixed token field. To connect:
564
667
 
565
- 1. Open `https://your-server.com/oauth/start?env=production` in a browser.
668
+ 1. Open `https://your-server.com/production/oauth/start` (or `/sandbox/oauth/start`) in a browser.
566
669
  2. Complete the eBay login flow.
567
670
  3. Copy the session token from the confirmation page.
568
671
  4. Paste it as your **API Key / Bearer token** in the platform's MCP connector settings.
569
- 5. Set the MCP endpoint URL to `https://your-server.com/mcp`.
672
+ 5. Set the MCP endpoint URL to `https://your-server.com/production/mcp` (or `/sandbox/mcp`).
570
673
 
571
674
  ---
572
675
 
@@ -586,6 +689,37 @@ EBAY_CONTENT_LANGUAGE=lang_COUNTRY
586
689
  EBAY_USER_REFRESH_TOKEN=your_refresh_token
587
690
  ```
588
691
 
692
+ Other supported env vars used by the current runtime:
693
+
694
+ ```bash
695
+ MCP_HOST=0.0.0.0 # optional HTTP bind host
696
+ EBAY_TOKEN_STORE_PATH=.ebay-user-tokens.json # legacy single-user file token store path
697
+ SESSION_TTL_SECONDS=2592000 # hosted mode: session token TTL (default 30 days)
698
+ ```
699
+
700
+ Notes:
701
+ - `EBAY_TOKEN_STORE_PATH` is part of the older local file-token-store path and is **not** used by the hosted multi-user KV/Redis auth flow.
702
+
703
+ Token env vars such as `EBAY_USER_REFRESH_TOKEN`, `EBAY_USER_ACCESS_TOKEN`, and `EBAY_APP_ACCESS_TOKEN`
704
+ should be treated as local single-user inputs or explicit manual override flows.
705
+ In hosted multi-user mode, OAuth state, user tokens, and session tokens are persisted in the configured
706
+ remote store (Cloudflare KV or Upstash Redis), not in environment variables.
707
+
708
+ For multi-user local or hosted deployments, use a persistent auth store:
709
+
710
+ - `EBAY_TOKEN_STORE_BACKEND=cloudflare-kv`, or
711
+ - `EBAY_TOKEN_STORE_BACKEND=upstash-redis`
712
+
713
+ Use `memory` only for tests or throwaway dev sessions, since all OAuth state,
714
+ user tokens, and session tokens are lost on restart.
715
+
716
+ Backend selection is driven by `EBAY_TOKEN_STORE_BACKEND` explicitly. Credentials alone do not select the backend:
717
+
718
+ - `EBAY_TOKEN_STORE_BACKEND=cloudflare-kv` → Cloudflare KV
719
+ - `EBAY_TOKEN_STORE_BACKEND=upstash-redis` → Upstash Redis
720
+
721
+ If the selected backend is missing required credentials, the server now fails loudly at startup instead of silently appearing to use the wrong store.
722
+
589
723
  ### OAuth Authentication
590
724
 
591
725
  **Client Credentials:** lower-rate, application-level access.
@@ -1,4 +1,5 @@
1
1
  import axios from 'axios';
2
+ import { Redis } from '@upstash/redis';
2
3
  /**
3
4
  * Purely in-memory KV store with optional per-entry TTL.
4
5
  * Useful for local development, single-user setups, or when
@@ -48,6 +49,9 @@ export class CloudflareKVStore {
48
49
  this.accountId = process.env.CLOUDFLARE_ACCOUNT_ID || '';
49
50
  this.namespaceId = process.env.CLOUDFLARE_KV_NAMESPACE_ID || '';
50
51
  const apiToken = process.env.CLOUDFLARE_API_TOKEN || '';
52
+ if (!this.accountId || !this.namespaceId || !apiToken) {
53
+ throw new Error('Cloudflare KV backend selected, but CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_KV_NAMESPACE_ID, and CLOUDFLARE_API_TOKEN must all be set.');
54
+ }
51
55
  this.client = axios.create({
52
56
  baseURL: `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/storage/kv/namespaces/${this.namespaceId}`,
53
57
  headers: {
@@ -106,6 +110,60 @@ export class CloudflareKVStore {
106
110
  this.cache.clear();
107
111
  }
108
112
  }
113
+ // ── Upstash Redis backend ────────────────────────────────────────────────────
114
+ /**
115
+ * Upstash Redis REST API backend with an in-memory read-through cache.
116
+ * Used when EBAY_TOKEN_STORE_BACKEND=upstash-redis.
117
+ */
118
+ export class UpstashRedisKVStore {
119
+ backendName = 'UpstashRedisKVStore';
120
+ client;
121
+ cache = new Map();
122
+ cacheTtlMs;
123
+ constructor(cacheTtlMs = 5 * 60 * 1_000) {
124
+ this.cacheTtlMs = cacheTtlMs;
125
+ const url = process.env.UPSTASH_REDIS_REST_URL || '';
126
+ const token = process.env.UPSTASH_REDIS_REST_TOKEN || '';
127
+ if (!url || !token) {
128
+ throw new Error('Upstash Redis backend selected, but UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN must both be set.');
129
+ }
130
+ this.client = new Redis({ url, token });
131
+ }
132
+ isCacheValid(entry) {
133
+ return Date.now() < entry.expiresAt;
134
+ }
135
+ async get(key) {
136
+ const cached = this.cache.get(key);
137
+ if (cached && this.isCacheValid(cached)) {
138
+ return cached.value;
139
+ }
140
+ const value = await this.client.get(key);
141
+ this.cache.set(key, { value, expiresAt: Date.now() + this.cacheTtlMs });
142
+ return value ?? null;
143
+ }
144
+ async put(key, value, expirationTtl) {
145
+ if (expirationTtl !== undefined) {
146
+ await this.client.set(key, value, { ex: expirationTtl });
147
+ }
148
+ else {
149
+ await this.client.set(key, value);
150
+ }
151
+ const cacheTtl = expirationTtl
152
+ ? Math.min(expirationTtl * 1_000, this.cacheTtlMs)
153
+ : this.cacheTtlMs;
154
+ this.cache.set(key, { value, expiresAt: Date.now() + cacheTtl });
155
+ }
156
+ async delete(key) {
157
+ await this.client.del(key);
158
+ this.cache.delete(key);
159
+ }
160
+ invalidate(key) {
161
+ this.cache.delete(key);
162
+ }
163
+ flushCache() {
164
+ this.cache.clear();
165
+ }
166
+ }
109
167
  // ── Singleton factory ─────────────────────────────────────────────────────────
110
168
  /**
111
169
  * Process-level singleton KV store.
@@ -126,8 +184,9 @@ let _kvStoreSingleton = null;
126
184
  * Returns (or lazily creates) the process-wide KV store singleton based on the
127
185
  * EBAY_TOKEN_STORE_BACKEND environment variable:
128
186
  *
129
- * memory → InMemoryKVStore (no external dependencies, data lost on restart)
130
- * cloudflare-kv → CloudflareKVStore (default; requires CLOUDFLARE_* env vars)
187
+ * memory → InMemoryKVStore (no external dependencies, data lost on restart)
188
+ * cloudflare-kv → CloudflareKVStore (requires CLOUDFLARE_* env vars)
189
+ * upstash-redis → UpstashRedisKVStore (requires UPSTASH_REDIS_REST_* env vars)
131
190
  *
132
191
  * If the variable is unset or unrecognised, defaults to cloudflare-kv so that
133
192
  * existing hosted deployments continue to work without any config change.
@@ -146,6 +205,13 @@ export function createKVStore() {
146
205
  _kvStoreSingleton = new InMemoryKVStore();
147
206
  break;
148
207
  }
208
+ case 'upstash-redis':
209
+ case 'upstash':
210
+ case 'redis': {
211
+ console.log(`[kv-store] EBAY_TOKEN_STORE_BACKEND="${rawEnv ?? ''}" → using UpstashRedisKVStore (url="${process.env.UPSTASH_REDIS_REST_URL ?? '(unset)'}")`);
212
+ _kvStoreSingleton = new UpstashRedisKVStore();
213
+ break;
214
+ }
149
215
  case 'cloudflare-kv':
150
216
  case 'cloudflare':
151
217
  default: {
@@ -1,5 +1,17 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import { createKVStore } from '../auth/kv-store.js';
3
+ // ── TTL constants (seconds) ────────────────────────────────────────────────
4
+ /** 15 minutes — matches the eBay OAuth state parameter lifetime. */
5
+ const OAUTH_STATE_TTL_S = 15 * 60;
6
+ /** 10 minutes — short-lived MCP authorization code. */
7
+ const AUTH_CODE_TTL_S = 10 * 60;
8
+ /** 30 days — configurable via SESSION_TTL_SECONDS env var. */
9
+ const SESSION_TTL_S = Number(process.env.SESSION_TTL_SECONDS ?? 30 * 24 * 60 * 60);
10
+ /** 18 months — default fallback when no refresh token expiry is available. */
11
+ const DEFAULT_REFRESH_TOKEN_TTL_S = 18 * 30 * 24 * 60 * 60;
12
+ function secondsFromNow(ttlSeconds) {
13
+ return new Date(Date.now() + ttlSeconds * 1_000).toISOString();
14
+ }
3
15
  export class MultiUserAuthStore {
4
16
  kv;
5
17
  /**
@@ -36,10 +48,11 @@ export class MultiUserAuthStore {
36
48
  state,
37
49
  environment,
38
50
  createdAt: new Date().toISOString(),
51
+ expiresAt: secondsFromNow(OAUTH_STATE_TTL_S),
39
52
  returnTo,
40
53
  ...mcpContext,
41
54
  };
42
- await this.kv.put(this.stateKey(state), record, 15 * 60);
55
+ await this.kv.put(this.stateKey(state), record, OAUTH_STATE_TTL_S);
43
56
  return record;
44
57
  }
45
58
  async consumeOAuthState(state) {
@@ -51,13 +64,25 @@ export class MultiUserAuthStore {
51
64
  return record;
52
65
  }
53
66
  async saveUserTokens(userId, environment, tokenData) {
67
+ // Derive TTL from refresh token expiry when available; fall back to 18 months.
68
+ let ttlSeconds = DEFAULT_REFRESH_TOKEN_TTL_S;
69
+ if (tokenData.userRefreshTokenExpiry) {
70
+ const expiryMs = typeof tokenData.userRefreshTokenExpiry === 'number'
71
+ ? tokenData.userRefreshTokenExpiry
72
+ : new Date(tokenData.userRefreshTokenExpiry).getTime();
73
+ const remaining = Math.floor((expiryMs - Date.now()) / 1_000);
74
+ if (remaining > 0) {
75
+ ttlSeconds = remaining;
76
+ }
77
+ }
54
78
  const record = {
55
79
  userId,
56
80
  environment,
57
81
  tokenData,
58
82
  updatedAt: new Date().toISOString(),
83
+ expiresAt: secondsFromNow(ttlSeconds),
59
84
  };
60
- await this.kv.put(this.userTokenKey(userId, environment), record);
85
+ await this.kv.put(this.userTokenKey(userId, environment), record, ttlSeconds);
61
86
  }
62
87
  async getUserTokens(userId, environment) {
63
88
  return await this.kv.get(this.userTokenKey(userId, environment));
@@ -70,9 +95,10 @@ export class MultiUserAuthStore {
70
95
  userId,
71
96
  environment,
72
97
  createdAt: now,
98
+ expiresAt: secondsFromNow(SESSION_TTL_S),
73
99
  lastUsedAt: now,
74
100
  };
75
- await this.kv.put(this.sessionKey(sessionToken), record);
101
+ await this.kv.put(this.sessionKey(sessionToken), record, SESSION_TTL_S);
76
102
  return record;
77
103
  }
78
104
  async getSession(sessionToken) {
@@ -91,8 +117,11 @@ export class MultiUserAuthStore {
91
117
  if (!record || record.revokedAt) {
92
118
  return;
93
119
  }
120
+ // Recalculate remaining TTL so Redis/KV doesn't expire an active session.
121
+ const expiresAt = new Date(record.expiresAt).getTime();
122
+ const remainingTtl = Math.max(Math.floor((expiresAt - now) / 1_000), SESSION_TTL_S);
94
123
  record.lastUsedAt = new Date(now).toISOString();
95
- await this.kv.put(this.sessionKey(sessionToken), record);
124
+ await this.kv.put(this.sessionKey(sessionToken), record, remainingTtl);
96
125
  this.sessionTouchCache.set(sessionToken, now);
97
126
  }
98
127
  async revokeSession(sessionToken) {
@@ -100,8 +129,11 @@ export class MultiUserAuthStore {
100
129
  if (!record) {
101
130
  return;
102
131
  }
132
+ // Keep whatever TTL remains — just mark as revoked.
133
+ const expiresAt = new Date(record.expiresAt).getTime();
134
+ const remainingTtl = Math.max(Math.floor((expiresAt - Date.now()) / 1_000), 60);
103
135
  record.revokedAt = new Date().toISOString();
104
- await this.kv.put(this.sessionKey(sessionToken), record);
136
+ await this.kv.put(this.sessionKey(sessionToken), record, remainingTtl);
105
137
  }
106
138
  async deleteSession(sessionToken) {
107
139
  await this.kv.delete(this.sessionKey(sessionToken));
@@ -164,8 +196,9 @@ export class MultiUserAuthStore {
164
196
  userId,
165
197
  environment,
166
198
  createdAt: new Date().toISOString(),
199
+ expiresAt: secondsFromNow(AUTH_CODE_TTL_S),
167
200
  };
168
- await this.kv.put(`auth_code:${code}`, record, 10 * 60); // 10 min TTL
201
+ await this.kv.put(`auth_code:${code}`, record, AUTH_CODE_TTL_S);
169
202
  return record;
170
203
  }
171
204
  async consumeAuthCode(code) {
@@ -1,4 +1,3 @@
1
- import { config } from 'dotenv';
2
1
  import { existsSync, readFileSync } from 'fs';
3
2
  import { fileURLToPath } from 'url';
4
3
  import { dirname, join } from 'path';
@@ -6,7 +5,6 @@ import { LocaleEnum } from '../types/ebay-enums.js';
6
5
  import { getVersion } from '../utils/version.js';
7
6
  const __filename = fileURLToPath(import.meta.url);
8
7
  const __dirname = dirname(__filename);
9
- config({ path: join(__dirname, '../../.env'), quiet: true });
10
8
  function readSecretConfigFile() {
11
9
  const configFile = process.env.EBAY_CONFIG_FILE;
12
10
  if (!configFile || !existsSync(configFile)) {
@@ -189,14 +187,34 @@ export function validateEnvironmentConfig() {
189
187
  const environment = getConfiguredEnvironment();
190
188
  const configForEnv = getSecretConfigForEnvironment(environment);
191
189
  if (!configForEnv) {
192
- if (!process.env.EBAY_CLIENT_ID) {
190
+ // Mirror the same fallback logic used in getEbayConfig() so that
191
+ // per-environment overrides (EBAY_PRODUCTION_CLIENT_ID etc.) are
192
+ // recognised here too and don't produce false-positive errors.
193
+ const effectiveClientId = environment === 'production'
194
+ ? process.env.EBAY_PRODUCTION_CLIENT_ID || process.env.EBAY_CLIENT_ID
195
+ : process.env.EBAY_SANDBOX_CLIENT_ID || process.env.EBAY_CLIENT_ID;
196
+ const effectiveClientSecret = environment === 'production'
197
+ ? process.env.EBAY_PRODUCTION_CLIENT_SECRET || process.env.EBAY_CLIENT_SECRET
198
+ : process.env.EBAY_SANDBOX_CLIENT_SECRET || process.env.EBAY_CLIENT_SECRET;
199
+ // EBAY_RUNAME is the preferred name (it's a RuName string, not a URL).
200
+ // EBAY_REDIRECT_URI is kept for backward compatibility.
201
+ const effectiveRuName = environment === 'production'
202
+ ? process.env.EBAY_PRODUCTION_RUNAME ||
203
+ process.env.EBAY_PRODUCTION_REDIRECT_URI ||
204
+ process.env.EBAY_RUNAME ||
205
+ process.env.EBAY_REDIRECT_URI
206
+ : process.env.EBAY_SANDBOX_RUNAME ||
207
+ process.env.EBAY_SANDBOX_REDIRECT_URI ||
208
+ process.env.EBAY_RUNAME ||
209
+ process.env.EBAY_REDIRECT_URI;
210
+ if (!effectiveClientId) {
193
211
  errors.push('EBAY_CLIENT_ID is not set. OAuth will not work.');
194
212
  }
195
- if (!process.env.EBAY_CLIENT_SECRET) {
213
+ if (!effectiveClientSecret) {
196
214
  errors.push('EBAY_CLIENT_SECRET is not set. OAuth will not work.');
197
215
  }
198
- if (!process.env.EBAY_REDIRECT_URI) {
199
- warnings.push('EBAY_REDIRECT_URI is not set for selected environment.');
216
+ if (!effectiveRuName) {
217
+ warnings.push('EBAY_RUNAME (or legacy EBAY_REDIRECT_URI) is not set for selected environment.');
200
218
  }
201
219
  }
202
220
  }
@@ -215,9 +233,17 @@ export function getEbayConfig(environmentOverride) {
215
233
  const fallbackClientSecret = environment === 'production'
216
234
  ? process.env.EBAY_PRODUCTION_CLIENT_SECRET || process.env.EBAY_CLIENT_SECRET || ''
217
235
  : process.env.EBAY_SANDBOX_CLIENT_SECRET || process.env.EBAY_CLIENT_SECRET || '';
236
+ // Preferred var is EBAY_RUNAME (clearer naming — it IS the RuName, not a URL).
237
+ // EBAY_REDIRECT_URI is kept for backward compatibility.
218
238
  const fallbackRedirectUri = environment === 'production'
219
- ? process.env.EBAY_PRODUCTION_REDIRECT_URI || process.env.EBAY_REDIRECT_URI
220
- : process.env.EBAY_SANDBOX_REDIRECT_URI || process.env.EBAY_REDIRECT_URI;
239
+ ? process.env.EBAY_PRODUCTION_RUNAME ||
240
+ process.env.EBAY_PRODUCTION_REDIRECT_URI ||
241
+ process.env.EBAY_RUNAME ||
242
+ process.env.EBAY_REDIRECT_URI
243
+ : process.env.EBAY_SANDBOX_RUNAME ||
244
+ process.env.EBAY_SANDBOX_REDIRECT_URI ||
245
+ process.env.EBAY_RUNAME ||
246
+ process.env.EBAY_REDIRECT_URI;
221
247
  return {
222
248
  clientId: secretConfig?.clientId || fallbackClientId,
223
249
  clientSecret: secretConfig?.clientSecret || fallbackClientSecret,
@@ -12,10 +12,7 @@
12
12
  import { dirname, join } from 'path';
13
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
14
14
  import { homedir, platform } from 'os';
15
- import { config } from 'dotenv';
16
15
  import { fileURLToPath } from 'url';
17
- // Load environment variables silently
18
- config({ quiet: true });
19
16
  const __filename = fileURLToPath(import.meta.url);
20
17
  const __dirname = dirname(__filename);
21
18
  const PROJECT_ROOT = join(__dirname, '../..');