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 +168 -34
- package/build/auth/kv-store.js +68 -2
- package/build/auth/multi-user-store.js +39 -6
- package/build/config/environment.js +34 -8
- package/build/scripts/auto-setup.js +0 -3
- package/build/scripts/interactive-setup.js +11 -8
- package/build/scripts/run-with-local-env.js +50 -0
- package/build/scripts/setup.js +13 -10
- package/build/server-http.js +340 -313
- package/build/utils/oauth-helper.js +264 -162
- package/package.json +21 -14
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
|
|
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
|
-
|
|
466
|
+
**Preferred — environment-scoped paths (no `?env=` needed):**
|
|
408
467
|
|
|
409
468
|
```text
|
|
410
|
-
/oauth/start
|
|
469
|
+
/sandbox/oauth/start # always sandbox
|
|
470
|
+
/production/oauth/start # always production
|
|
411
471
|
```
|
|
412
472
|
|
|
413
|
-
|
|
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
|
|
480
|
+
If `OAUTH_START_KEY` is configured, include it as a query param or header:
|
|
420
481
|
|
|
421
482
|
```text
|
|
422
|
-
/oauth/start?
|
|
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
|
|
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
|
-
####
|
|
549
|
+
#### Auth behavior (all paths)
|
|
479
550
|
|
|
480
|
-
- `GET /mcp` without a valid Bearer token redirects into browser OAuth
|
|
481
|
-
-
|
|
482
|
-
-
|
|
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.
|
|
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
|
|
521
|
-
2. It registers itself at `POST /register
|
|
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
|
|
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
|
|
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.
|
package/build/auth/kv-store.js
CHANGED
|
@@ -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
|
|
130
|
-
* cloudflare-kv
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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 (!
|
|
213
|
+
if (!effectiveClientSecret) {
|
|
196
214
|
errors.push('EBAY_CLIENT_SECRET is not set. OAuth will not work.');
|
|
197
215
|
}
|
|
198
|
-
if (!
|
|
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.
|
|
220
|
-
|
|
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, '../..');
|