paygate-mcp 3.7.0 → 3.9.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
@@ -183,7 +183,7 @@ class PayGateServer {
183
183
  this.audit.log('key.auto_topped_up', 'system', `Auto-topup: added ${amount} credits`, {
184
184
  keyMasked, creditsAdded: amount, newBalance,
185
185
  });
186
- this.gate.webhook?.emitAdmin('key.auto_topped_up', 'system', {
186
+ this.emitWebhookAdmin('key.auto_topped_up', 'system', {
187
187
  keyMasked, creditsAdded: amount, newBalance,
188
188
  });
189
189
  // Sync to Redis (if configured)
@@ -333,6 +333,8 @@ class PayGateServer {
333
333
  return this.handleSetAutoTopup(req, res);
334
334
  case '/topup':
335
335
  return this.handleTopUp(req, res);
336
+ case '/keys/transfer':
337
+ return this.handleCreditTransfer(req, res);
336
338
  case '/balance':
337
339
  return this.handleBalance(req, res);
338
340
  case '/limits':
@@ -373,6 +375,16 @@ class PayGateServer {
373
375
  break;
374
376
  case '/webhooks/stats':
375
377
  return this.handleWebhookStats(req, res);
378
+ case '/webhooks/filters':
379
+ if (req.method === 'GET')
380
+ return this.handleListWebhookFilters(req, res);
381
+ if (req.method === 'POST')
382
+ return this.handleCreateWebhookFilter(req, res);
383
+ break;
384
+ case '/webhooks/filters/update':
385
+ return this.handleUpdateWebhookFilter(req, res);
386
+ case '/webhooks/filters/delete':
387
+ return this.handleDeleteWebhookFilter(req, res);
376
388
  // ─── Team management endpoints ────────────────────────────────────
377
389
  case '/teams':
378
390
  if (req.method === 'GET')
@@ -611,7 +623,7 @@ class PayGateServer {
611
623
  const fired = this.alerts.check(apiKey, keyRecord, { rateLimitDenied: isRateLimited });
612
624
  // Send alert events via webhook
613
625
  for (const alert of fired) {
614
- this.gate.webhook?.emitAdmin('alert.fired', 'system', {
626
+ this.emitWebhookAdmin('alert.fired', 'system', {
615
627
  alertType: alert.type,
616
628
  keyPrefix: alert.keyPrefix,
617
629
  keyName: alert.keyName,
@@ -808,6 +820,7 @@ class PayGateServer {
808
820
  setAcl: 'POST /keys/acl — Set tool ACL (requires X-Admin-Key)',
809
821
  setExpiry: 'POST /keys/expiry — Set key expiry (requires X-Admin-Key)',
810
822
  topUp: 'POST /topup — Add credits (requires X-Admin-Key)',
823
+ transfer: 'POST /keys/transfer — Transfer credits between keys (requires X-Admin-Key)',
811
824
  usage: 'GET /usage — Export usage data (requires X-Admin-Key)',
812
825
  limits: 'POST /limits — Set spending limit (requires X-Admin-Key)',
813
826
  setQuota: 'POST /keys/quota — Set usage quota (requires X-Admin-Key)',
@@ -825,6 +838,9 @@ class PayGateServer {
825
838
  alerts: 'GET /alerts — Get pending alerts + POST /alerts — Configure alert rules (requires X-Admin-Key)',
826
839
  webhookDeadLetters: 'GET /webhooks/dead-letter — View failed webhook deliveries + DELETE to clear (requires X-Admin-Key)',
827
840
  webhookStats: 'GET /webhooks/stats — Webhook delivery statistics (requires X-Admin-Key)',
841
+ webhookFilters: 'GET|POST /webhooks/filters — List or create webhook filter rules (requires X-Admin-Key)',
842
+ updateWebhookFilter: 'POST /webhooks/filters/update — Update a webhook filter rule (requires X-Admin-Key)',
843
+ deleteWebhookFilter: 'POST /webhooks/filters/delete — Delete a webhook filter rule (requires X-Admin-Key)',
828
844
  teams: 'GET /teams — List teams + POST /teams — Create team (requires X-Admin-Key)',
829
845
  teamsUpdate: 'POST /teams/update — Update team (requires X-Admin-Key)',
830
846
  teamsDelete: 'POST /teams/delete — Delete team (requires X-Admin-Key)',
@@ -964,7 +980,7 @@ class PayGateServer {
964
980
  deniedTools: record.deniedTools,
965
981
  expiresAt: record.expiresAt,
966
982
  });
967
- this.gate.webhook?.emitAdmin('key.created', 'admin', {
983
+ this.emitWebhookAdmin('key.created', 'admin', {
968
984
  keyMasked: (0, audit_1.maskKeyForAudit)(record.key), name, credits,
969
985
  });
970
986
  res.writeHead(201, { 'Content-Type': 'application/json' });
@@ -1040,12 +1056,113 @@ class PayGateServer {
1040
1056
  creditsAdded: credits,
1041
1057
  newBalance: record?.credits,
1042
1058
  });
1043
- this.gate.webhook?.emitAdmin('key.topup', 'admin', {
1059
+ this.emitWebhookAdmin('key.topup', 'admin', {
1044
1060
  keyMasked: (0, audit_1.maskKeyForAudit)(params.key), creditsAdded: credits, newBalance: record?.credits,
1045
1061
  });
1046
1062
  res.writeHead(200, { 'Content-Type': 'application/json' });
1047
1063
  res.end(JSON.stringify({ credits: record?.credits, message: `Added ${credits} credits` }));
1048
1064
  }
1065
+ // ─── /keys/transfer — Transfer credits between keys ─────────────────────
1066
+ async handleCreditTransfer(req, res) {
1067
+ if (req.method !== 'POST') {
1068
+ res.writeHead(405, { 'Content-Type': 'application/json' });
1069
+ res.end(JSON.stringify({ error: 'Method not allowed' }));
1070
+ return;
1071
+ }
1072
+ if (!this.checkAdmin(req, res, 'admin'))
1073
+ return;
1074
+ const body = await this.readBody(req);
1075
+ let params;
1076
+ try {
1077
+ params = JSON.parse(body);
1078
+ }
1079
+ catch {
1080
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1081
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
1082
+ return;
1083
+ }
1084
+ if (!params.from || !params.to) {
1085
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1086
+ res.end(JSON.stringify({ error: 'Missing "from" and "to" API keys' }));
1087
+ return;
1088
+ }
1089
+ if (params.from === params.to) {
1090
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1091
+ res.end(JSON.stringify({ error: 'Cannot transfer credits to the same key' }));
1092
+ return;
1093
+ }
1094
+ const credits = Math.floor(Number(params.credits));
1095
+ if (!Number.isFinite(credits) || credits <= 0) {
1096
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1097
+ res.end(JSON.stringify({ error: 'Credits must be a positive integer' }));
1098
+ return;
1099
+ }
1100
+ // Validate source key exists and has enough credits
1101
+ const sourceRecord = this.gate.store.getKey(params.from);
1102
+ if (!sourceRecord) {
1103
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1104
+ res.end(JSON.stringify({ error: 'Source key not found' }));
1105
+ return;
1106
+ }
1107
+ if (sourceRecord.credits < credits) {
1108
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1109
+ res.end(JSON.stringify({
1110
+ error: `Insufficient credits: source has ${sourceRecord.credits}, need ${credits}`,
1111
+ }));
1112
+ return;
1113
+ }
1114
+ // Validate destination key exists (getKey returns null for revoked/expired keys)
1115
+ const destRecord = this.gate.store.getKey(params.to);
1116
+ if (!destRecord) {
1117
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1118
+ res.end(JSON.stringify({ error: 'Destination key not found' }));
1119
+ return;
1120
+ }
1121
+ // Perform transfer atomically (deduct from source, add to destination)
1122
+ if (this.redisSync) {
1123
+ // Redis atomic transfer: deduct first, then add
1124
+ const deducted = await this.redisSync.atomicDeduct(params.from, credits);
1125
+ if (!deducted) {
1126
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1127
+ res.end(JSON.stringify({ error: 'Redis deduction failed (insufficient credits or key not found)' }));
1128
+ return;
1129
+ }
1130
+ await this.redisSync.atomicTopup(params.to, credits);
1131
+ }
1132
+ else {
1133
+ // Local store: deduct and add
1134
+ sourceRecord.credits -= credits;
1135
+ destRecord.credits += credits;
1136
+ this.gate.store.save();
1137
+ }
1138
+ const fromBalance = this.gate.store.getKey(params.from)?.credits ?? 0;
1139
+ const toBalance = this.gate.store.getKey(params.to)?.credits ?? 0;
1140
+ const memo = params.memo || '';
1141
+ this.audit.log('key.credits_transferred', 'admin', `Transferred ${credits} credits`, {
1142
+ fromKeyMasked: (0, audit_1.maskKeyForAudit)(params.from),
1143
+ toKeyMasked: (0, audit_1.maskKeyForAudit)(params.to),
1144
+ credits,
1145
+ fromBalance,
1146
+ toBalance,
1147
+ memo,
1148
+ });
1149
+ this.emitWebhookAdmin('key.credits_transferred', 'admin', {
1150
+ fromKeyMasked: (0, audit_1.maskKeyForAudit)(params.from),
1151
+ toKeyMasked: (0, audit_1.maskKeyForAudit)(params.to),
1152
+ credits,
1153
+ fromBalance,
1154
+ toBalance,
1155
+ memo,
1156
+ });
1157
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1158
+ res.end(JSON.stringify({
1159
+ transferred: credits,
1160
+ from: { keyMasked: (0, audit_1.maskKeyForAudit)(params.from), balance: fromBalance },
1161
+ to: { keyMasked: (0, audit_1.maskKeyForAudit)(params.to), balance: toBalance },
1162
+ memo: memo || undefined,
1163
+ message: `Transferred ${credits} credits`,
1164
+ }));
1165
+ }
1049
1166
  // ─── /keys/revoke — Revoke a key ──────────────────────────────────────────
1050
1167
  async handleRevokeKey(req, res) {
1051
1168
  if (req.method !== 'POST') {
@@ -1086,7 +1203,7 @@ class PayGateServer {
1086
1203
  this.audit.log('key.revoked', 'admin', `Key revoked`, {
1087
1204
  keyMasked: (0, audit_1.maskKeyForAudit)(params.key),
1088
1205
  });
1089
- this.gate.webhook?.emitAdmin('key.revoked', 'admin', {
1206
+ this.emitWebhookAdmin('key.revoked', 'admin', {
1090
1207
  keyMasked: (0, audit_1.maskKeyForAudit)(params.key),
1091
1208
  });
1092
1209
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1132,7 +1249,7 @@ class PayGateServer {
1132
1249
  oldKeyMasked: (0, audit_1.maskKeyForAudit)(params.key),
1133
1250
  newKeyMasked: (0, audit_1.maskKeyForAudit)(rotated.key),
1134
1251
  });
1135
- this.gate.webhook?.emitAdmin('key.rotated', 'admin', {
1252
+ this.emitWebhookAdmin('key.rotated', 'admin', {
1136
1253
  oldKeyMasked: (0, audit_1.maskKeyForAudit)(params.key),
1137
1254
  newKeyMasked: (0, audit_1.maskKeyForAudit)(rotated.key),
1138
1255
  });
@@ -1479,7 +1596,7 @@ class PayGateServer {
1479
1596
  keyMasked: (0, audit_1.maskKeyForAudit)(params.key),
1480
1597
  threshold, amount, maxDaily,
1481
1598
  });
1482
- this.gate.webhook?.emitAdmin('key.auto_topup_configured', 'admin', {
1599
+ this.emitWebhookAdmin('key.auto_topup_configured', 'admin', {
1483
1600
  keyMasked: (0, audit_1.maskKeyForAudit)(params.key), threshold, amount, maxDaily,
1484
1601
  });
1485
1602
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -2084,13 +2201,151 @@ class PayGateServer {
2084
2201
  return;
2085
2202
  }
2086
2203
  const stats = this.gate.webhook.getRetryStats();
2204
+ const routerStats = this.gate.webhookRouter ? this.gate.webhookRouter.getAggregateStats() : null;
2087
2205
  res.writeHead(200, { 'Content-Type': 'application/json' });
2088
2206
  res.end(JSON.stringify({
2089
2207
  configured: true,
2090
2208
  maxRetries: this.gate.webhook.maxRetries,
2091
2209
  ...stats,
2210
+ ...(routerStats ? { filters: routerStats } : {}),
2092
2211
  }));
2093
2212
  }
2213
+ // ─── /webhooks/filters — Webhook filter CRUD ─────────────────────────────
2214
+ handleListWebhookFilters(req, res) {
2215
+ if (!this.checkAdmin(req, res))
2216
+ return;
2217
+ if (!this.gate.webhookRouter) {
2218
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2219
+ res.end(JSON.stringify({ count: 0, filters: [], message: 'No webhook router configured' }));
2220
+ return;
2221
+ }
2222
+ const filters = this.gate.webhookRouter.listRules();
2223
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2224
+ res.end(JSON.stringify({ count: filters.length, filters }));
2225
+ }
2226
+ async handleCreateWebhookFilter(req, res) {
2227
+ if (!this.checkAdmin(req, res, 'admin'))
2228
+ return;
2229
+ if (!this.gate.webhookRouter) {
2230
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2231
+ res.end(JSON.stringify({ error: 'No webhook configured. Set webhookUrl or webhookFilters to enable webhook routing.' }));
2232
+ return;
2233
+ }
2234
+ const body = await this.readBody(req);
2235
+ let params;
2236
+ try {
2237
+ params = JSON.parse(body);
2238
+ }
2239
+ catch {
2240
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2241
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
2242
+ return;
2243
+ }
2244
+ try {
2245
+ const rule = this.gate.webhookRouter.addRule({
2246
+ id: '', // auto-generated
2247
+ name: String(params.name || ''),
2248
+ events: Array.isArray(params.events) ? params.events.map(String) : [],
2249
+ url: String(params.url || ''),
2250
+ secret: params.secret ? String(params.secret) : undefined,
2251
+ keyPrefixes: Array.isArray(params.keyPrefixes) ? params.keyPrefixes.map(String) : undefined,
2252
+ active: params.active !== false,
2253
+ });
2254
+ this.audit.log('webhook_filter.created', 'admin', `Webhook filter created: ${rule.name}`, { filterId: rule.id, name: rule.name });
2255
+ res.writeHead(201, { 'Content-Type': 'application/json' });
2256
+ res.end(JSON.stringify(rule));
2257
+ }
2258
+ catch (err) {
2259
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2260
+ res.end(JSON.stringify({ error: err.message }));
2261
+ }
2262
+ }
2263
+ async handleUpdateWebhookFilter(req, res) {
2264
+ if (req.method !== 'POST') {
2265
+ res.writeHead(405, { 'Content-Type': 'application/json' });
2266
+ res.end(JSON.stringify({ error: 'Method not allowed. Use POST.' }));
2267
+ return;
2268
+ }
2269
+ if (!this.checkAdmin(req, res, 'admin'))
2270
+ return;
2271
+ if (!this.gate.webhookRouter) {
2272
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2273
+ res.end(JSON.stringify({ error: 'No webhook router configured' }));
2274
+ return;
2275
+ }
2276
+ const body = await this.readBody(req);
2277
+ let params;
2278
+ try {
2279
+ params = JSON.parse(body);
2280
+ }
2281
+ catch {
2282
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2283
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
2284
+ return;
2285
+ }
2286
+ const filterId = String(params.id || '');
2287
+ if (!filterId) {
2288
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2289
+ res.end(JSON.stringify({ error: 'Missing id field' }));
2290
+ return;
2291
+ }
2292
+ try {
2293
+ const rule = this.gate.webhookRouter.updateRule(filterId, {
2294
+ name: params.name !== undefined ? String(params.name) : undefined,
2295
+ events: Array.isArray(params.events) ? params.events.map(String) : undefined,
2296
+ url: params.url !== undefined ? String(params.url) : undefined,
2297
+ secret: params.secret !== undefined ? String(params.secret) : undefined,
2298
+ keyPrefixes: Array.isArray(params.keyPrefixes) ? params.keyPrefixes.map(String) : undefined,
2299
+ active: params.active !== undefined ? Boolean(params.active) : undefined,
2300
+ });
2301
+ this.audit.log('webhook_filter.updated', 'admin', `Webhook filter updated: ${rule.name}`, { filterId: rule.id });
2302
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2303
+ res.end(JSON.stringify(rule));
2304
+ }
2305
+ catch (err) {
2306
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2307
+ res.end(JSON.stringify({ error: err.message }));
2308
+ }
2309
+ }
2310
+ async handleDeleteWebhookFilter(req, res) {
2311
+ if (req.method !== 'POST') {
2312
+ res.writeHead(405, { 'Content-Type': 'application/json' });
2313
+ res.end(JSON.stringify({ error: 'Method not allowed. Use POST.' }));
2314
+ return;
2315
+ }
2316
+ if (!this.checkAdmin(req, res, 'admin'))
2317
+ return;
2318
+ if (!this.gate.webhookRouter) {
2319
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2320
+ res.end(JSON.stringify({ error: 'No webhook router configured' }));
2321
+ return;
2322
+ }
2323
+ const body = await this.readBody(req);
2324
+ let params;
2325
+ try {
2326
+ params = JSON.parse(body);
2327
+ }
2328
+ catch {
2329
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2330
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
2331
+ return;
2332
+ }
2333
+ const filterId = String(params.id || '');
2334
+ if (!filterId) {
2335
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2336
+ res.end(JSON.stringify({ error: 'Missing id field' }));
2337
+ return;
2338
+ }
2339
+ const deleted = this.gate.webhookRouter.deleteRule(filterId);
2340
+ if (!deleted) {
2341
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2342
+ res.end(JSON.stringify({ error: 'Filter not found' }));
2343
+ return;
2344
+ }
2345
+ this.audit.log('webhook_filter.deleted', 'admin', `Webhook filter deleted: ${filterId}`, { filterId });
2346
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2347
+ res.end(JSON.stringify({ ok: true, message: `Filter ${filterId} deleted` }));
2348
+ }
2094
2349
  // ─── /audit — Query audit log ─────────────────────────────────────────────
2095
2350
  handleAudit(req, res) {
2096
2351
  if (req.method !== 'GET') {
@@ -2881,7 +3136,7 @@ class PayGateServer {
2881
3136
  newKeyMasked: record.key.slice(0, 7) + '...' + record.key.slice(-4),
2882
3137
  role,
2883
3138
  });
2884
- this.gate.webhook?.emitAdmin('admin_key.created', callerMasked, {
3139
+ this.emitWebhookAdmin('admin_key.created', callerMasked, {
2885
3140
  newKeyMasked: record.key.slice(0, 7) + '...' + record.key.slice(-4),
2886
3141
  name: params.name,
2887
3142
  role,
@@ -2935,7 +3190,7 @@ class PayGateServer {
2935
3190
  this.audit.log('admin_key.revoked', callerMasked, `Revoked admin key ${targetMasked}`, {
2936
3191
  revokedKeyMasked: targetMasked,
2937
3192
  });
2938
- this.gate.webhook?.emitAdmin('admin_key.revoked', callerMasked, {
3193
+ this.emitWebhookAdmin('admin_key.revoked', callerMasked, {
2939
3194
  revokedKeyMasked: targetMasked,
2940
3195
  });
2941
3196
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -2946,6 +3201,17 @@ class PayGateServer {
2946
3201
  * (setAcl, setExpiry, setQuota, setTags, setIpAllowlist, setSpendingLimit).
2947
3202
  * Fire-and-forget: errors logged, never thrown.
2948
3203
  */
3204
+ /**
3205
+ * Route admin webhook events through the WebhookRouter (for filter rules) or direct emitter.
3206
+ */
3207
+ emitWebhookAdmin(type, actor, metadata = {}) {
3208
+ if (this.gate.webhookRouter) {
3209
+ this.gate.webhookRouter.emitAdmin(type, actor, metadata);
3210
+ }
3211
+ else if (this.gate.webhook) {
3212
+ this.gate.webhook.emitAdmin(type, actor, metadata);
3213
+ }
3214
+ }
2949
3215
  syncKeyMutation(apiKey) {
2950
3216
  if (!this.redisSync)
2951
3217
  return;