paygate-mcp 2.9.0 → 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/dist/server.js CHANGED
@@ -45,6 +45,7 @@ const alerts_1 = require("./alerts");
45
45
  const teams_1 = require("./teams");
46
46
  const redis_client_1 = require("./redis-client");
47
47
  const redis_sync_1 = require("./redis-sync");
48
+ const tokens_1 = require("./tokens");
48
49
  /** Max request body size: 1MB */
49
50
  const MAX_BODY_SIZE = 1_048_576;
50
51
  class PayGateServer {
@@ -75,6 +76,8 @@ class PayGateServer {
75
76
  teams;
76
77
  /** Redis sync adapter for distributed state (null if not using Redis) */
77
78
  redisSync = null;
79
+ /** Scoped token manager for short-lived delegated tokens */
80
+ tokens;
78
81
  /** Server start time (ms since epoch) */
79
82
  startedAt = Date.now();
80
83
  /** Whether the server is draining (shutting down gracefully) */
@@ -158,6 +161,11 @@ class PayGateServer {
158
161
  this.gate.teamRecorder = (apiKey, credits) => {
159
162
  this.teams.recordUsage(apiKey, credits);
160
163
  };
164
+ // Scoped token manager (uses admin key as signing secret, padded to min length)
165
+ const tokenSecret = this.adminKey.length >= 8
166
+ ? this.adminKey
167
+ : this.adminKey + require('crypto').randomBytes(8).toString('hex');
168
+ this.tokens = new tokens_1.ScopedTokenManager(tokenSecret);
161
169
  // Redis distributed state (if configured)
162
170
  if (redisUrl) {
163
171
  const redisOpts = (0, redis_client_1.parseRedisUrl)(redisUrl);
@@ -173,6 +181,10 @@ class PayGateServer {
173
181
  this.gate.onCreditsDeducted = (apiKey, amount) => {
174
182
  sync.atomicDeduct(apiKey, amount).catch(() => { });
175
183
  };
184
+ // Wire token revocation sync: incoming pub/sub → local revocation list
185
+ sync.onTokenRevoked = (fingerprint, expiresAt, revokedAt, reason) => {
186
+ this.tokens.revocationList.addEntry({ fingerprint, expiresAt, revokedAt, reason });
187
+ };
176
188
  }
177
189
  }
178
190
  async start() {
@@ -309,6 +321,15 @@ class PayGateServer {
309
321
  // ─── Multi-tenant namespace endpoints ──────────────────────────────
310
322
  case '/namespaces':
311
323
  return this.handleListNamespaces(req, res);
324
+ // ─── Scoped token endpoints ────────────────────────────────────────
325
+ case '/tokens':
326
+ if (req.method === 'POST')
327
+ return this.handleCreateToken(req, res);
328
+ break;
329
+ case '/tokens/revoke':
330
+ return this.handleRevokeToken(req, res);
331
+ case '/tokens/revoked':
332
+ return this.handleListRevokedTokens(req, res);
312
333
  // ─── OAuth 2.1 endpoints ─────────────────────────────────────────
313
334
  case '/.well-known/oauth-authorization-server':
314
335
  return this.handleOAuthMetadata(req, res);
@@ -372,6 +393,8 @@ class PayGateServer {
372
393
  // Extract client IP for IP allowlist checking
373
394
  const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim()
374
395
  || req.socket.remoteAddress || '';
396
+ // Extract scoped token tool restrictions (set by resolveApiKey)
397
+ const scopedTokenTools = req._scopedTokenTools;
375
398
  // ─── Batch tool calls ────────────────────────────────────────────────
376
399
  if (request.method === 'tools/call_batch') {
377
400
  const params = request.params;
@@ -387,7 +410,7 @@ class PayGateServer {
387
410
  res.end(JSON.stringify(errResp));
388
411
  return;
389
412
  }
390
- const batchResponse = await this.handler.handleBatchRequest(calls, request.id, apiKey, clientIp);
413
+ const batchResponse = await this.handler.handleBatchRequest(calls, request.id, apiKey, clientIp, scopedTokenTools);
391
414
  // Audit + metrics for batch
392
415
  if (batchResponse.error) {
393
416
  this.audit.log('gate.deny', (0, audit_1.maskKeyForAudit)(apiKey || 'anonymous'), `Batch denied (${calls.length} calls)`, {
@@ -429,7 +452,7 @@ class PayGateServer {
429
452
  }
430
453
  return;
431
454
  }
432
- const response = await this.handler.handleRequest(request, apiKey, clientIp);
455
+ const response = await this.handler.handleRequest(request, apiKey, clientIp, scopedTokenTools);
433
456
  // Inject pricing metadata into tools/list responses
434
457
  if (request.method === 'tools/list' && response.result) {
435
458
  const result = response.result;
@@ -577,17 +600,42 @@ class PayGateServer {
577
600
  res.end(JSON.stringify({ message: 'Session terminated' }));
578
601
  }
579
602
  /**
580
- * Resolve API key from X-API-Key header or OAuth Bearer token.
603
+ * Resolve API key from X-API-Key header, scoped token, or OAuth Bearer token.
604
+ * Priority: X-API-Key → pgt_ scoped token → OAuth Bearer token.
605
+ * Also stores resolved token metadata for ACL narrowing.
581
606
  */
582
607
  resolveApiKey(req) {
583
608
  let apiKey = req.headers['x-api-key'] || null;
584
- if (!apiKey && this.oauth) {
609
+ // Check if X-API-Key is actually a scoped token
610
+ if (apiKey && tokens_1.ScopedTokenManager.isToken(apiKey)) {
611
+ const validation = this.tokens.validate(apiKey);
612
+ if (validation.valid && validation.payload) {
613
+ // Store token metadata on request for ACL narrowing
614
+ req._scopedTokenTools = validation.payload.allowedTools;
615
+ return validation.payload.apiKey;
616
+ }
617
+ return null; // Invalid/expired token
618
+ }
619
+ // Check Bearer token (OAuth or scoped token)
620
+ if (!apiKey) {
585
621
  const authHeader = req.headers['authorization'];
586
622
  if (authHeader?.startsWith('Bearer ')) {
587
623
  const bearerToken = authHeader.slice(7);
588
- const tokenInfo = this.oauth.validateToken(bearerToken);
589
- if (tokenInfo) {
590
- apiKey = tokenInfo.apiKey;
624
+ // Try scoped token first
625
+ if (tokens_1.ScopedTokenManager.isToken(bearerToken)) {
626
+ const validation = this.tokens.validate(bearerToken);
627
+ if (validation.valid && validation.payload) {
628
+ req._scopedTokenTools = validation.payload.allowedTools;
629
+ return validation.payload.apiKey;
630
+ }
631
+ return null;
632
+ }
633
+ // Fall back to OAuth
634
+ if (this.oauth) {
635
+ const tokenInfo = this.oauth.validateToken(bearerToken);
636
+ if (tokenInfo) {
637
+ apiKey = tokenInfo.apiKey;
638
+ }
591
639
  }
592
640
  }
593
641
  }
@@ -669,6 +717,9 @@ class PayGateServer {
669
717
  teamsAssign: 'POST /teams/assign — Assign key to team (requires X-Admin-Key)',
670
718
  teamsRemove: 'POST /teams/remove — Remove key from team (requires X-Admin-Key)',
671
719
  teamsUsage: 'GET /teams/usage?teamId=... — Team usage summary (requires X-Admin-Key)',
720
+ createToken: 'POST /tokens — Create scoped token (requires X-Admin-Key)',
721
+ revokeToken: 'POST /tokens/revoke — Revoke a scoped token (requires X-Admin-Key)',
722
+ listRevokedTokens: 'GET /tokens/revoked — List revoked tokens (requires X-Admin-Key)',
672
723
  ...(this.oauth ? {
673
724
  oauthMetadata: 'GET /.well-known/oauth-authorization-server — OAuth 2.1 server metadata',
674
725
  oauthRegister: 'POST /oauth/register — Register OAuth client',
@@ -2177,6 +2228,135 @@ class PayGateServer {
2177
2228
  res.writeHead(200, { 'Content-Type': 'application/json' });
2178
2229
  res.end(JSON.stringify({ namespaces, count: namespaces.length }, null, 2));
2179
2230
  }
2231
+ // ─── /tokens — Create scoped token ──────────────────────────────────────────
2232
+ async handleCreateToken(req, res) {
2233
+ if (!this.checkAdmin(req, res))
2234
+ return;
2235
+ const body = await this.readBody(req);
2236
+ let params;
2237
+ try {
2238
+ params = JSON.parse(body);
2239
+ }
2240
+ catch {
2241
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2242
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
2243
+ return;
2244
+ }
2245
+ if (!params.key) {
2246
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2247
+ res.end(JSON.stringify({ error: 'Missing required param: key (API key to delegate from)' }));
2248
+ return;
2249
+ }
2250
+ // Verify the parent key exists and is active
2251
+ const keyRecord = this.gate.store.getKey(params.key);
2252
+ if (!keyRecord || !keyRecord.active) {
2253
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2254
+ res.end(JSON.stringify({ error: 'API key not found or inactive' }));
2255
+ return;
2256
+ }
2257
+ const ttl = Math.max(1, Math.min(86400, Math.floor(Number(params.ttl) || 3600)));
2258
+ const token = this.tokens.create({
2259
+ apiKey: params.key,
2260
+ ttlSeconds: ttl,
2261
+ allowedTools: params.allowedTools,
2262
+ label: params.label,
2263
+ });
2264
+ this.audit.log('token.created', 'admin', `Scoped token created for key: ${keyRecord.name}`, {
2265
+ keyMasked: (0, audit_1.maskKeyForAudit)(params.key),
2266
+ ttl,
2267
+ allowedTools: params.allowedTools,
2268
+ label: params.label,
2269
+ });
2270
+ res.writeHead(201, { 'Content-Type': 'application/json' });
2271
+ res.end(JSON.stringify({
2272
+ token,
2273
+ expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
2274
+ ttl,
2275
+ parentKey: keyRecord.name,
2276
+ allowedTools: params.allowedTools || [],
2277
+ label: params.label || null,
2278
+ message: 'Use this token as X-API-Key or Bearer token. It will expire automatically.',
2279
+ }));
2280
+ }
2281
+ // ─── /tokens/revoke — Revoke a scoped token ────────────────────────────────
2282
+ async handleRevokeToken(req, res) {
2283
+ if (req.method !== 'POST') {
2284
+ res.writeHead(405, { 'Content-Type': 'application/json' });
2285
+ res.end(JSON.stringify({ error: 'Method not allowed' }));
2286
+ return;
2287
+ }
2288
+ if (!this.checkAdmin(req, res))
2289
+ return;
2290
+ const body = await this.readBody(req);
2291
+ let params;
2292
+ try {
2293
+ params = JSON.parse(body);
2294
+ }
2295
+ catch {
2296
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2297
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
2298
+ return;
2299
+ }
2300
+ if (!params.token) {
2301
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2302
+ res.end(JSON.stringify({ error: 'Missing required param: token' }));
2303
+ return;
2304
+ }
2305
+ // Validate that it's a real, validly-signed token (even if expired)
2306
+ if (!tokens_1.ScopedTokenManager.isToken(params.token)) {
2307
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2308
+ res.end(JSON.stringify({ error: 'Not a scoped token (must start with pgt_)' }));
2309
+ return;
2310
+ }
2311
+ const entry = this.tokens.revokeToken(params.token, params.reason);
2312
+ if (!entry) {
2313
+ // Already revoked or invalid signature
2314
+ res.writeHead(409, { 'Content-Type': 'application/json' });
2315
+ res.end(JSON.stringify({ error: 'Token already revoked or invalid signature' }));
2316
+ return;
2317
+ }
2318
+ this.audit.log('token.revoked', 'admin', `Scoped token revoked`, {
2319
+ fingerprint: entry.fingerprint.slice(0, 12) + '...',
2320
+ expiresAt: entry.expiresAt,
2321
+ reason: entry.reason,
2322
+ });
2323
+ // Sync to Redis so other instances reject this token too
2324
+ if (this.redisSync) {
2325
+ this.redisSync.publishEvent({
2326
+ type: 'token_revoked',
2327
+ key: entry.fingerprint,
2328
+ data: { expiresAt: entry.expiresAt, revokedAt: entry.revokedAt, reason: entry.reason },
2329
+ }).catch(() => { });
2330
+ }
2331
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2332
+ res.end(JSON.stringify({
2333
+ message: 'Token revoked',
2334
+ fingerprint: entry.fingerprint,
2335
+ expiresAt: entry.expiresAt,
2336
+ revokedAt: entry.revokedAt,
2337
+ }));
2338
+ }
2339
+ // ─── /tokens/revoked — List revoked tokens ─────────────────────────────────
2340
+ handleListRevokedTokens(req, res) {
2341
+ if (req.method !== 'GET') {
2342
+ res.writeHead(405, { 'Content-Type': 'application/json' });
2343
+ res.end(JSON.stringify({ error: 'Method not allowed' }));
2344
+ return;
2345
+ }
2346
+ if (!this.checkAdmin(req, res))
2347
+ return;
2348
+ const entries = this.tokens.revocationList.list();
2349
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2350
+ res.end(JSON.stringify({
2351
+ count: entries.length,
2352
+ entries: entries.map(e => ({
2353
+ fingerprint: e.fingerprint,
2354
+ expiresAt: e.expiresAt,
2355
+ revokedAt: e.revokedAt,
2356
+ reason: e.reason || null,
2357
+ })),
2358
+ }));
2359
+ }
2180
2360
  /**
2181
2361
  * Sync a key mutation to Redis. Call after any local KeyStore mutation
2182
2362
  * (setAcl, setExpiry, setQuota, setTags, setIpAllowlist, setSpendingLimit).
@@ -2215,6 +2395,7 @@ class PayGateServer {
2215
2395
  this.oauth?.destroy();
2216
2396
  this.sessions.destroy();
2217
2397
  this.audit.destroy();
2398
+ this.tokens.destroy();
2218
2399
  if (this.redisSync) {
2219
2400
  await this.redisSync.destroy();
2220
2401
  }
@@ -2265,6 +2446,7 @@ class PayGateServer {
2265
2446
  this.oauth?.destroy();
2266
2447
  this.sessions.destroy();
2267
2448
  this.audit.destroy();
2449
+ this.tokens.destroy();
2268
2450
  if (this.redisSync) {
2269
2451
  await this.redisSync.destroy();
2270
2452
  }