@frontmcp/plugin-approval 1.8.0 → 1.8.1

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/esm/index.mjs CHANGED
@@ -18,14 +18,42 @@ var __decorateClass = (decorators, target, key, kind) => {
18
18
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
19
19
 
20
20
  // plugins/plugin-approval/src/approval.plugin.ts
21
- import { DynamicPlugin as DynamicPlugin2, Plugin as Plugin2, ProviderScope as ProviderScope4, FRONTMCP_CONTEXT } from "@frontmcp/sdk";
22
-
23
- // plugins/plugin-approval/src/stores/approval-storage.store.ts
24
- import { Provider, ProviderScope } from "@frontmcp/sdk";
25
21
  import {
26
- createStorage,
27
- createMemoryStorage
28
- } from "@frontmcp/utils";
22
+ DynamicPlugin as DynamicPlugin2,
23
+ FRONTMCP_CONTEXT,
24
+ Plugin as Plugin2,
25
+ ProviderScope as ProviderScope4
26
+ } from "@frontmcp/sdk";
27
+
28
+ // plugins/plugin-approval/src/approval.identity.ts
29
+ import { STATELESS_SESSION_ID } from "@frontmcp/sdk";
30
+ import { randomUUID } from "@frontmcp/utils";
31
+ function nonEmptyString(value) {
32
+ return typeof value === "string" && value.length > 0 ? value : void 0;
33
+ }
34
+ function resolveApprovalIdentity(ctx) {
35
+ const extra = ctx?.authInfo?.extra;
36
+ const userId = nonEmptyString(extra?.["userId"]) ?? nonEmptyString(extra?.["sub"]) ?? nonEmptyString(ctx?.authInfo?.clientId);
37
+ const sessionId = nonEmptyString(ctx?.sessionId);
38
+ if (sessionId && sessionId !== STATELESS_SESSION_ID) {
39
+ return { sessionId, userId };
40
+ }
41
+ return { sessionId: userId ? `stateless-user:${userId}` : `unidentified:${randomUUID()}`, userId };
42
+ }
43
+
44
+ // plugins/plugin-approval/src/approval.symbols.ts
45
+ var ApprovalStoreToken = /* @__PURE__ */ Symbol.for(
46
+ "plugin:approval:store"
47
+ );
48
+ var ApprovalServiceToken = /* @__PURE__ */ Symbol.for(
49
+ "plugin:approval:service"
50
+ );
51
+ var ChallengeServiceToken = /* @__PURE__ */ Symbol.for(
52
+ "plugin:approval:challenge-service"
53
+ );
54
+
55
+ // plugins/plugin-approval/src/hooks/approval-check.hook.ts
56
+ import { DynamicPlugin, Plugin, ToolHook } from "@frontmcp/sdk";
29
57
 
30
58
  // plugins/plugin-approval/src/approval/types.ts
31
59
  var ApprovalScope = /* @__PURE__ */ ((ApprovalScope2) => {
@@ -390,269 +418,125 @@ var ChallengeValidationError = class extends ApprovalError {
390
418
  }
391
419
  };
392
420
 
393
- // plugins/plugin-approval/src/stores/approval-storage.store.ts
394
- function escapePattern(str) {
395
- return str.replace(/[*?[\]\\]/g, "\\$&");
396
- }
397
- var ApprovalStorageStore = class {
398
- storage;
399
- options;
400
- cleanupInterval;
401
- initialized = false;
402
- ownedStorage = false;
403
- constructor(options = {}) {
404
- this.options = {
405
- storage: options.storage ?? { type: "auto" },
406
- storageInstance: options.storageInstance,
407
- namespace: options.namespace ?? "approval",
408
- cleanupIntervalSeconds: options.cleanupIntervalSeconds ?? 60
409
- };
410
- }
411
- /**
412
- * Initialize the storage connection.
413
- */
414
- async initialize() {
415
- if (this.initialized) {
421
+ // plugins/plugin-approval/src/hooks/approval-check.hook.ts
422
+ var passedApprovalStores = /* @__PURE__ */ new WeakMap();
423
+ var ApprovalCheckPlugin = class extends DynamicPlugin {
424
+ async checkApproval(flowCtx) {
425
+ const { tool, toolContext } = flowCtx.state;
426
+ if (!tool || !toolContext) return;
427
+ const metadata = tool.metadata;
428
+ const approvalConfig = this.resolveApprovalConfig(
429
+ metadata["approval"]
430
+ );
431
+ if (!approvalConfig.required) {
416
432
  return;
417
433
  }
418
- if (this.options.storageInstance) {
419
- this.storage = this.options.storageInstance.namespace(this.options.namespace);
420
- this.ownedStorage = false;
421
- } else {
422
- const rootStorage = await createStorage(this.options.storage);
423
- this.storage = rootStorage.namespace(this.options.namespace);
424
- this.ownedStorage = true;
425
- }
426
- if (this.options.cleanupIntervalSeconds > 0) {
427
- this.cleanupInterval = setInterval(() => {
428
- void this.clearExpiredApprovals();
429
- }, this.options.cleanupIntervalSeconds * 1e3);
430
- this.cleanupInterval.unref?.();
434
+ if (approvalConfig.skipApproval) {
435
+ return;
431
436
  }
432
- this.initialized = true;
437
+ const approvalStore = this.get(ApprovalStoreToken);
438
+ const passedStores = passedApprovalStores.get(toolContext) ?? /* @__PURE__ */ new WeakSet();
439
+ if (passedStores.has(approvalStore)) return;
440
+ await this.enforceApproval(flowCtx, tool, toolContext, approvalConfig, approvalStore);
441
+ passedStores.add(approvalStore);
442
+ passedApprovalStores.set(toolContext, passedStores);
433
443
  }
434
- ensureInitialized() {
435
- if (!this.initialized) {
436
- throw new Error("ApprovalStorageStore not initialized. Call initialize() first.");
444
+ async enforceApproval(flowCtx, tool, toolContext, approvalConfig, approvalStore) {
445
+ const { sessionId, userId } = resolveApprovalIdentity(toolContext.tryGetContext?.());
446
+ const approval = await approvalStore.getApproval(tool.fullName, sessionId, userId);
447
+ if (approval?.state === "denied" /* DENIED */) {
448
+ throw new ApprovalRequiredError({
449
+ toolId: tool.fullName,
450
+ state: "denied",
451
+ message: `Tool "${tool.fullName}" execution denied.`
452
+ });
437
453
  }
438
- }
439
- buildKey(toolId, sessionId, userId, context) {
440
- const parts = [toolId];
441
- if (sessionId) parts.push(`session:${sessionId}`);
442
- if (userId) parts.push(`user:${userId}`);
443
- if (context) parts.push(`ctx:${context.type}:${context.identifier}`);
444
- return parts.join(":");
445
- }
446
- parseRecord(value) {
447
- if (!value) return void 0;
448
- try {
449
- const parsed = JSON.parse(value);
450
- const result = approvalRecordSchema.safeParse(parsed);
451
- if (!result.success) {
452
- return void 0;
453
- }
454
- return result.data;
455
- } catch {
456
- return void 0;
454
+ if (this.isPreApprovedContext(approvalConfig, this.getCurrentContext(flowCtx))) {
455
+ return;
457
456
  }
458
- }
459
- isExpired(approval) {
460
- return approval.expiresAt !== void 0 && Date.now() > approval.expiresAt;
461
- }
462
- async getApproval(toolId, sessionId, userId) {
463
- this.ensureInitialized();
464
- const sessionKey = this.buildKey(toolId, sessionId);
465
- const sessionValue = await this.storage.get(sessionKey);
466
- const sessionApproval = this.parseRecord(sessionValue);
467
- if (sessionApproval && !this.isExpired(sessionApproval)) {
468
- return sessionApproval;
457
+ if (approvalConfig.alwaysPrompt) {
458
+ await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
459
+ return;
469
460
  }
470
- if (userId) {
471
- const userKey = this.buildKey(toolId, void 0, userId);
472
- const userValue = await this.storage.get(userKey);
473
- const userApproval = this.parseRecord(userValue);
474
- if (userApproval && !this.isExpired(userApproval)) {
475
- return userApproval;
461
+ if (approval?.state === "approved" /* APPROVED */) {
462
+ if (!this.isExpired(approval)) {
463
+ return;
476
464
  }
477
465
  }
478
- return void 0;
466
+ await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
479
467
  }
480
- async queryApprovals(query) {
481
- this.ensureInitialized();
482
- const results = [];
483
- const pattern = query.toolId ? `${query.toolId}:*` : "*";
484
- const keys = await this.storage.keys(pattern);
485
- const values = await this.storage.mget(keys);
486
- for (const value of values) {
487
- const approval = this.parseRecord(value);
488
- if (!approval) continue;
489
- if (!query.includeExpired && this.isExpired(approval)) {
490
- continue;
491
- }
492
- if (query.toolId && approval.toolId !== query.toolId) continue;
493
- if (query.toolIds && !query.toolIds.includes(approval.toolId)) continue;
494
- if (query.scope && approval.scope !== query.scope) continue;
495
- if (query.scopes && !query.scopes.includes(approval.scope)) continue;
496
- if (query.state && approval.state !== query.state) continue;
497
- if (query.states && !query.states.includes(approval.state)) continue;
498
- if (query.sessionId && approval.sessionId !== query.sessionId) continue;
499
- if (query.userId && approval.userId !== query.userId) continue;
500
- if (query.context) {
501
- if (!approval.context || approval.context.type !== query.context.type || approval.context.identifier !== query.context.identifier) {
502
- continue;
503
- }
504
- }
505
- results.push(approval);
468
+ resolveApprovalConfig(config) {
469
+ if (config === true) {
470
+ return { required: true, defaultScope: "session" /* SESSION */ };
506
471
  }
507
- return results;
508
- }
509
- async grantApproval(options) {
510
- this.ensureInitialized();
511
- const now = Date.now();
512
- const expiresAt = options.ttlMs ? now + options.ttlMs : void 0;
513
- const grantedBy = normalizeGrantor(options.grantedBy);
514
- const record = {
515
- toolId: options.toolId,
516
- state: "approved" /* APPROVED */,
517
- scope: options.scope,
518
- grantedAt: now,
519
- expiresAt,
520
- ttlMs: options.ttlMs,
521
- sessionId: options.sessionId,
522
- userId: options.userId,
523
- context: options.context,
524
- grantedBy,
525
- reason: options.reason,
526
- metadata: options.metadata
472
+ if (config === false || config === void 0) {
473
+ return { required: false };
474
+ }
475
+ return {
476
+ ...config,
477
+ required: config.required ?? true,
478
+ defaultScope: config.defaultScope ?? "session" /* SESSION */
527
479
  };
528
- const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
529
- const ttlSeconds = options.ttlMs ? Math.ceil(options.ttlMs / 1e3) : void 0;
530
- await this.storage.set(key, JSON.stringify(record), { ttlSeconds });
531
- return record;
532
480
  }
533
- async revokeApproval(options) {
534
- this.ensureInitialized();
535
- const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
536
- const exists = await this.storage.exists(key);
537
- if (exists) {
538
- await this.storage.delete(key);
539
- return true;
540
- }
541
- return false;
481
+ isExpired(approval) {
482
+ if (!approval.expiresAt) return false;
483
+ return Date.now() > approval.expiresAt;
542
484
  }
543
- async isApproved(toolId, sessionId, userId, context) {
544
- this.ensureInitialized();
545
- if (context) {
546
- const contextKey = this.buildKey(toolId, sessionId, userId, context);
547
- const contextValue = await this.storage.get(contextKey);
548
- const contextApproval = this.parseRecord(contextValue);
549
- if (contextApproval && contextApproval.state === "approved" /* APPROVED */ && !this.isExpired(contextApproval)) {
550
- return true;
551
- }
552
- }
553
- const sessionKey = this.buildKey(toolId, sessionId);
554
- const sessionValue = await this.storage.get(sessionKey);
555
- const sessionApproval = this.parseRecord(sessionValue);
556
- if (sessionApproval && sessionApproval.state === "approved" /* APPROVED */ && !this.isExpired(sessionApproval)) {
557
- return true;
558
- }
559
- if (userId) {
560
- const userKey = this.buildKey(toolId, void 0, userId);
561
- const userValue = await this.storage.get(userKey);
562
- const userApproval = this.parseRecord(userValue);
563
- if (userApproval && userApproval.state === "approved" /* APPROVED */ && !this.isExpired(userApproval)) {
564
- return true;
565
- }
566
- }
567
- return false;
485
+ /**
486
+ * The context this call is running in, as established by the SESSION (GHSA-r848-p7wf-96rc).
487
+ *
488
+ * Deliberately ignores `toolContext.input.context`. The only consumer of this value is
489
+ * `isPreApprovedContext`, which skips the approval prompt outright, so reading it from the
490
+ * arguments of the call being gated let a caller name its own pre-approved context and walk
491
+ * straight past the gate. Only `authInfo.extra.approvalContext`, which the server sets
492
+ * while authenticating, can say what context a call belongs to.
493
+ */
494
+ getCurrentContext(flowCtx) {
495
+ const ctx = flowCtx.state.toolContext?.tryGetContext?.();
496
+ const sessionContext = ctx?.authInfo?.extra?.["approvalContext"];
497
+ return this.isApprovalContext(sessionContext) ? sessionContext : void 0;
568
498
  }
569
- async clearSessionApprovals(sessionId) {
570
- this.ensureInitialized();
571
- const escapedSessionId = escapePattern(sessionId);
572
- const pattern = `*:session:${escapedSessionId}*`;
573
- const keys = await this.storage.keys(pattern);
574
- if (keys.length === 0) {
575
- return 0;
499
+ isApprovalContext(value) {
500
+ return typeof value === "object" && value !== null && "type" in value && "identifier" in value && typeof value.type === "string" && typeof value.identifier === "string";
501
+ }
502
+ isPreApprovedContext(config, currentContext) {
503
+ if (!currentContext || !config.preApprovedContexts?.length) {
504
+ return false;
576
505
  }
577
- return await this.storage.mdelete(keys);
506
+ return config.preApprovedContexts.some(
507
+ (preApproved) => preApproved.type === currentContext.type && preApproved.identifier === currentContext.identifier
508
+ );
578
509
  }
579
- async clearExpiredApprovals() {
580
- this.ensureInitialized();
581
- const now = Date.now();
582
- const keys = await this.storage.keys("*");
583
- const values = await this.storage.mget(keys);
584
- const keysToDelete = [];
585
- for (let i = 0; i < keys.length; i++) {
586
- const approval = this.parseRecord(values[i]);
587
- if (approval && approval.expiresAt && approval.expiresAt <= now) {
588
- keysToDelete.push(keys[i]);
589
- }
590
- }
591
- if (keysToDelete.length > 0) {
592
- return await this.storage.mdelete(keysToDelete);
593
- }
594
- return 0;
595
- }
596
- async getStats() {
597
- this.ensureInitialized();
598
- const byScope = {
599
- ["session" /* SESSION */]: 0,
600
- ["user" /* USER */]: 0,
601
- ["time_limited" /* TIME_LIMITED */]: 0,
602
- ["tool_specific" /* TOOL_SPECIFIC */]: 0,
603
- ["context_specific" /* CONTEXT_SPECIFIC */]: 0
604
- };
605
- const byState = {
606
- ["pending" /* PENDING */]: 0,
607
- ["approved" /* APPROVED */]: 0,
608
- ["denied" /* DENIED */]: 0,
609
- ["expired" /* EXPIRED */]: 0
610
- };
611
- const keys = await this.storage.keys("*");
612
- const values = await this.storage.mget(keys);
613
- let total = 0;
614
- for (const value of values) {
615
- const approval = this.parseRecord(value);
616
- if (approval) {
617
- total++;
618
- byScope[approval.scope]++;
619
- byState[approval.state]++;
510
+ async handleApprovalRequired(flowCtx, config, existingApproval) {
511
+ const { tool } = flowCtx.state;
512
+ const message = config.approvalMessage ?? `Tool "${tool?.fullName}" requires approval to execute. Allow?`;
513
+ const isExpiredApproval = existingApproval ? this.isExpired(existingApproval) : false;
514
+ throw new ApprovalRequiredError({
515
+ toolId: tool?.fullName ?? "unknown",
516
+ state: isExpiredApproval ? "expired" : "pending",
517
+ message,
518
+ approvalOptions: {
519
+ allowedScopes: config.allowedScopes,
520
+ defaultScope: config.defaultScope,
521
+ maxTtlMs: config.maxTtlMs,
522
+ category: config.category,
523
+ riskLevel: config.riskLevel
620
524
  }
621
- }
622
- return {
623
- totalApprovals: total,
624
- byScope,
625
- byState
626
- };
627
- }
628
- async close() {
629
- if (this.cleanupInterval) {
630
- clearInterval(this.cleanupInterval);
631
- this.cleanupInterval = void 0;
632
- }
633
- if (this.ownedStorage && this.storage) {
634
- await this.storage.root.disconnect();
635
- }
636
- this.initialized = false;
525
+ });
637
526
  }
638
527
  };
639
- ApprovalStorageStore = __decorateClass([
640
- Provider({
641
- name: "provider:approval:store:storage",
642
- description: "Storage-backed approval store (supports Memory, Redis, Vercel KV, Upstash)",
643
- scope: ProviderScope.GLOBAL
528
+ __decorateClass([
529
+ ToolHook.Will("execute", { priority: 100 })
530
+ ], ApprovalCheckPlugin.prototype, "checkApproval", 1);
531
+ ApprovalCheckPlugin = __decorateClass([
532
+ Plugin({
533
+ name: "approval:check",
534
+ description: "Checks tool approval state before execution"
644
535
  })
645
- ], ApprovalStorageStore);
646
- function createApprovalMemoryStore(options = {}) {
647
- const memoryStorage = createMemoryStorage();
648
- return new ApprovalStorageStore({
649
- ...options,
650
- storageInstance: memoryStorage
651
- });
652
- }
536
+ ], ApprovalCheckPlugin);
653
537
 
654
538
  // plugins/plugin-approval/src/services/approval.service.ts
655
- import { Provider as Provider2, ProviderScope as ProviderScope2 } from "@frontmcp/sdk";
539
+ import { Provider, ProviderScope } from "@frontmcp/sdk";
656
540
  var ApprovalService = class {
657
541
  constructor(store, sessionId, userId) {
658
542
  this.store = store;
@@ -794,10 +678,10 @@ var ApprovalService = class {
794
678
  }
795
679
  };
796
680
  ApprovalService = __decorateClass([
797
- Provider2({
681
+ Provider({
798
682
  name: "provider:approval:service",
799
683
  description: "Service for managing tool approvals",
800
- scope: ProviderScope2.CONTEXT
684
+ scope: ProviderScope.CONTEXT
801
685
  })
802
686
  ], ApprovalService);
803
687
  function createApprovalService(store, sessionId, userId) {
@@ -805,12 +689,12 @@ function createApprovalService(store, sessionId, userId) {
805
689
  }
806
690
 
807
691
  // plugins/plugin-approval/src/services/challenge.service.ts
808
- import { Provider as Provider3, ProviderScope as ProviderScope3 } from "@frontmcp/sdk";
692
+ import { Provider as Provider2, ProviderScope as ProviderScope2 } from "@frontmcp/sdk";
809
693
  import {
810
694
  generatePkcePair,
811
695
  generateCodeChallenge,
812
- createStorage as createStorage2,
813
- createMemoryStorage as createMemoryStorage2
696
+ createStorage,
697
+ createMemoryStorage
814
698
  } from "@frontmcp/utils";
815
699
  var ChallengeService = class {
816
700
  storage;
@@ -834,7 +718,7 @@ var ChallengeService = class {
834
718
  this.storage = this.options.storageInstance.namespace(this.options.namespace);
835
719
  this.ownedStorage = false;
836
720
  } else {
837
- const rootStorage = await createStorage2(this.options.storage);
721
+ const rootStorage = await createStorage(this.options.storage);
838
722
  this.storage = rootStorage.namespace(this.options.namespace);
839
723
  this.ownedStorage = true;
840
724
  }
@@ -944,10 +828,10 @@ var ChallengeService = class {
944
828
  }
945
829
  };
946
830
  ChallengeService = __decorateClass([
947
- Provider3({
831
+ Provider2({
948
832
  name: "provider:approval:challenge-service",
949
833
  description: "PKCE challenge service for webhook approval flows",
950
- scope: ProviderScope3.GLOBAL
834
+ scope: ProviderScope2.GLOBAL
951
835
  })
952
836
  ], ChallengeService);
953
837
  function generatePkcePairFromVerifier(codeVerifier) {
@@ -957,141 +841,279 @@ function generatePkcePairFromVerifier(codeVerifier) {
957
841
  };
958
842
  }
959
843
  function createMemoryChallengeService(options = {}) {
960
- const memoryStorage = createMemoryStorage2();
844
+ const memoryStorage = createMemoryStorage();
961
845
  return new ChallengeService({
962
846
  ...options,
963
847
  storageInstance: memoryStorage
964
848
  });
965
849
  }
966
850
 
967
- // plugins/plugin-approval/src/approval.symbols.ts
968
- var ApprovalStoreToken = /* @__PURE__ */ Symbol.for(
969
- "plugin:approval:store"
970
- );
971
- var ApprovalServiceToken = /* @__PURE__ */ Symbol.for(
972
- "plugin:approval:service"
973
- );
974
- var ChallengeServiceToken = /* @__PURE__ */ Symbol.for(
975
- "plugin:approval:challenge-service"
976
- );
977
-
978
- // plugins/plugin-approval/src/hooks/approval-check.hook.ts
979
- import { DynamicPlugin, Plugin, ToolHook } from "@frontmcp/sdk";
980
- var ApprovalCheckPlugin = class extends DynamicPlugin {
981
- async checkApproval(flowCtx) {
982
- const { tool, toolContext } = flowCtx.state;
983
- if (!tool || !toolContext) return;
984
- const metadata = tool.metadata;
985
- const approvalConfig = this.resolveApprovalConfig(
986
- metadata["approval"]
987
- );
988
- if (!approvalConfig.required) {
989
- return;
990
- }
991
- if (approvalConfig.skipApproval) {
992
- return;
993
- }
994
- const ctx = toolContext.tryGetContext?.();
995
- const sessionId = ctx?.sessionId ?? "unknown";
996
- const userId = this.getStringExtra(ctx?.authInfo?.extra, "userId") ?? this.getStringExtra(ctx?.authInfo?.extra, "sub") ?? ctx?.authInfo?.clientId;
997
- const currentContext = this.getCurrentContext(flowCtx);
998
- if (this.isPreApprovedContext(approvalConfig, currentContext)) {
999
- return;
1000
- }
1001
- const approvalStore = this.get(ApprovalStoreToken);
1002
- const approval = await approvalStore.getApproval(tool.fullName, sessionId, userId);
1003
- if (approvalConfig.alwaysPrompt) {
1004
- await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
851
+ // plugins/plugin-approval/src/stores/approval-storage.store.ts
852
+ import { Provider as Provider3, ProviderScope as ProviderScope3 } from "@frontmcp/sdk";
853
+ import {
854
+ createMemoryStorage as createMemoryStorage2,
855
+ createStorage as createStorage2
856
+ } from "@frontmcp/utils";
857
+ function escapePattern(str) {
858
+ return str.replace(/[*?[\]\\]/g, "\\$&");
859
+ }
860
+ var ApprovalStorageStore = class {
861
+ storage;
862
+ options;
863
+ cleanupInterval;
864
+ initialized = false;
865
+ ownedStorage = false;
866
+ constructor(options = {}) {
867
+ this.options = {
868
+ storage: options.storage ?? { type: "auto" },
869
+ storageInstance: options.storageInstance,
870
+ namespace: options.namespace ?? "approval",
871
+ cleanupIntervalSeconds: options.cleanupIntervalSeconds ?? 60
872
+ };
873
+ }
874
+ /**
875
+ * Initialize the storage connection.
876
+ */
877
+ async initialize() {
878
+ if (this.initialized) {
1005
879
  return;
1006
880
  }
1007
- if (approval?.state === "approved" /* APPROVED */) {
1008
- if (!this.isExpired(approval)) {
1009
- return;
1010
- }
881
+ if (this.options.storageInstance) {
882
+ this.storage = this.options.storageInstance.namespace(this.options.namespace);
883
+ this.ownedStorage = false;
884
+ } else {
885
+ const rootStorage = await createStorage2(this.options.storage);
886
+ this.storage = rootStorage.namespace(this.options.namespace);
887
+ this.ownedStorage = true;
1011
888
  }
1012
- if (approval?.state === "denied" /* DENIED */) {
1013
- throw new ApprovalRequiredError({
1014
- toolId: tool.fullName,
1015
- state: "denied",
1016
- message: `Tool "${tool.fullName}" execution denied.`
1017
- });
889
+ if (this.options.cleanupIntervalSeconds > 0) {
890
+ this.cleanupInterval = setInterval(() => {
891
+ void this.clearExpiredApprovals();
892
+ }, this.options.cleanupIntervalSeconds * 1e3);
893
+ this.cleanupInterval.unref?.();
1018
894
  }
1019
- await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
895
+ this.initialized = true;
1020
896
  }
1021
- resolveApprovalConfig(config) {
1022
- if (config === true) {
1023
- return { required: true, defaultScope: "session" /* SESSION */ };
897
+ ensureInitialized() {
898
+ if (!this.initialized) {
899
+ throw new Error("ApprovalStorageStore not initialized. Call initialize() first.");
1024
900
  }
1025
- if (config === false || config === void 0) {
1026
- return { required: false };
901
+ }
902
+ buildKey(toolId, sessionId, userId, context) {
903
+ const parts = [toolId];
904
+ if (sessionId) parts.push(`session:${sessionId}`);
905
+ if (userId) parts.push(`user:${userId}`);
906
+ if (context) parts.push(`ctx:${context.type}:${context.identifier}`);
907
+ return parts.join(":");
908
+ }
909
+ parseRecord(value) {
910
+ if (!value) return void 0;
911
+ try {
912
+ const parsed = JSON.parse(value);
913
+ const result = approvalRecordSchema.safeParse(parsed);
914
+ if (!result.success) {
915
+ return void 0;
916
+ }
917
+ return result.data;
918
+ } catch {
919
+ return void 0;
1027
920
  }
1028
- return {
1029
- ...config,
1030
- required: config.required ?? true,
1031
- defaultScope: config.defaultScope ?? "session" /* SESSION */
1032
- };
1033
921
  }
1034
922
  isExpired(approval) {
1035
- if (!approval.expiresAt) return false;
1036
- return Date.now() > approval.expiresAt;
923
+ return approval.expiresAt !== void 0 && Date.now() > approval.expiresAt;
1037
924
  }
1038
925
  /**
1039
- * The context this call is running in, as established by the SESSION (GHSA-r848-p7wf-96rc).
1040
- *
1041
- * Deliberately ignores `toolContext.input.context`. The only consumer of this value is
1042
- * `isPreApprovedContext`, which skips the approval prompt outright, so reading it from the
1043
- * arguments of the call being gated let a caller name its own pre-approved context and walk
1044
- * straight past the gate. Only `authInfo.extra.approvalContext`, which the server sets
1045
- * while authenticating, can say what context a call belongs to.
926
+ * The session record, else the user record, except that a denial in either scope wins,
927
+ * so a session approval cannot mask a user-level denial.
1046
928
  */
1047
- getCurrentContext(flowCtx) {
1048
- const ctx = flowCtx.state.toolContext?.tryGetContext?.();
1049
- const sessionContext = ctx?.authInfo?.extra?.["approvalContext"];
1050
- return this.isApprovalContext(sessionContext) ? sessionContext : void 0;
929
+ async getApproval(toolId, sessionId, userId) {
930
+ this.ensureInitialized();
931
+ const keys = [this.buildKey(toolId, sessionId)];
932
+ if (userId) {
933
+ keys.push(this.buildKey(toolId, void 0, userId));
934
+ }
935
+ const activeRecords = [];
936
+ for (const key of keys) {
937
+ const record = this.parseRecord(await this.storage.get(key));
938
+ if (record && !this.isExpired(record)) {
939
+ activeRecords.push(record);
940
+ }
941
+ }
942
+ return activeRecords.find((record) => record.state === "denied" /* DENIED */) ?? activeRecords[0];
1051
943
  }
1052
- isApprovalContext(value) {
1053
- return typeof value === "object" && value !== null && "type" in value && "identifier" in value && typeof value.type === "string" && typeof value.identifier === "string";
944
+ async queryApprovals(query) {
945
+ this.ensureInitialized();
946
+ const results = [];
947
+ const pattern = query.toolId ? `${query.toolId}:*` : "*";
948
+ const keys = await this.storage.keys(pattern);
949
+ const values = await this.storage.mget(keys);
950
+ for (const value of values) {
951
+ const approval = this.parseRecord(value);
952
+ if (!approval) continue;
953
+ if (!query.includeExpired && this.isExpired(approval)) {
954
+ continue;
955
+ }
956
+ if (query.toolId && approval.toolId !== query.toolId) continue;
957
+ if (query.toolIds && !query.toolIds.includes(approval.toolId)) continue;
958
+ if (query.scope && approval.scope !== query.scope) continue;
959
+ if (query.scopes && !query.scopes.includes(approval.scope)) continue;
960
+ if (query.state && approval.state !== query.state) continue;
961
+ if (query.states && !query.states.includes(approval.state)) continue;
962
+ if (query.sessionId && approval.sessionId !== query.sessionId) continue;
963
+ if (query.userId && approval.userId !== query.userId) continue;
964
+ if (query.context) {
965
+ if (!approval.context || approval.context.type !== query.context.type || approval.context.identifier !== query.context.identifier) {
966
+ continue;
967
+ }
968
+ }
969
+ results.push(approval);
970
+ }
971
+ return results;
1054
972
  }
1055
- getStringExtra(extra, key) {
1056
- if (!extra) return void 0;
1057
- const value = extra[key];
1058
- return typeof value === "string" ? value : void 0;
973
+ async grantApproval(options) {
974
+ this.ensureInitialized();
975
+ const now = Date.now();
976
+ const expiresAt = options.ttlMs ? now + options.ttlMs : void 0;
977
+ const grantedBy = normalizeGrantor(options.grantedBy);
978
+ const record = {
979
+ toolId: options.toolId,
980
+ state: "approved" /* APPROVED */,
981
+ scope: options.scope,
982
+ grantedAt: now,
983
+ expiresAt,
984
+ ttlMs: options.ttlMs,
985
+ sessionId: options.sessionId,
986
+ userId: options.userId,
987
+ context: options.context,
988
+ grantedBy,
989
+ reason: options.reason,
990
+ metadata: options.metadata
991
+ };
992
+ const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
993
+ const ttlSeconds = options.ttlMs ? Math.ceil(options.ttlMs / 1e3) : void 0;
994
+ await this.storage.set(key, JSON.stringify(record), { ttlSeconds });
995
+ return record;
1059
996
  }
1060
- isPreApprovedContext(config, currentContext) {
1061
- if (!currentContext || !config.preApprovedContexts?.length) {
1062
- return false;
997
+ async revokeApproval(options) {
998
+ this.ensureInitialized();
999
+ const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
1000
+ const exists = await this.storage.exists(key);
1001
+ if (exists) {
1002
+ await this.storage.delete(key);
1003
+ return true;
1063
1004
  }
1064
- return config.preApprovedContexts.some(
1065
- (preApproved) => preApproved.type === currentContext.type && preApproved.identifier === currentContext.identifier
1066
- );
1005
+ return false;
1067
1006
  }
1068
- async handleApprovalRequired(flowCtx, config, existingApproval) {
1069
- const { tool } = flowCtx.state;
1070
- const message = config.approvalMessage ?? `Tool "${tool?.fullName}" requires approval to execute. Allow?`;
1071
- const isExpiredApproval = existingApproval ? this.isExpired(existingApproval) : false;
1072
- throw new ApprovalRequiredError({
1073
- toolId: tool?.fullName ?? "unknown",
1074
- state: isExpiredApproval ? "expired" : "pending",
1075
- message,
1076
- approvalOptions: {
1077
- allowedScopes: config.allowedScopes,
1078
- defaultScope: config.defaultScope,
1079
- maxTtlMs: config.maxTtlMs,
1080
- category: config.category,
1081
- riskLevel: config.riskLevel
1007
+ async isApproved(toolId, sessionId, userId, context) {
1008
+ this.ensureInitialized();
1009
+ if (context) {
1010
+ const contextKey = this.buildKey(toolId, sessionId, userId, context);
1011
+ const contextValue = await this.storage.get(contextKey);
1012
+ const contextApproval = this.parseRecord(contextValue);
1013
+ if (contextApproval && contextApproval.state === "approved" /* APPROVED */ && !this.isExpired(contextApproval)) {
1014
+ return true;
1082
1015
  }
1083
- });
1016
+ }
1017
+ const sessionKey = this.buildKey(toolId, sessionId);
1018
+ const sessionValue = await this.storage.get(sessionKey);
1019
+ const sessionApproval = this.parseRecord(sessionValue);
1020
+ if (sessionApproval && sessionApproval.state === "approved" /* APPROVED */ && !this.isExpired(sessionApproval)) {
1021
+ return true;
1022
+ }
1023
+ if (userId) {
1024
+ const userKey = this.buildKey(toolId, void 0, userId);
1025
+ const userValue = await this.storage.get(userKey);
1026
+ const userApproval = this.parseRecord(userValue);
1027
+ if (userApproval && userApproval.state === "approved" /* APPROVED */ && !this.isExpired(userApproval)) {
1028
+ return true;
1029
+ }
1030
+ }
1031
+ return false;
1032
+ }
1033
+ async clearSessionApprovals(sessionId) {
1034
+ this.ensureInitialized();
1035
+ const escapedSessionId = escapePattern(sessionId);
1036
+ const pattern = `*:session:${escapedSessionId}*`;
1037
+ const keys = await this.storage.keys(pattern);
1038
+ if (keys.length === 0) {
1039
+ return 0;
1040
+ }
1041
+ return await this.storage.mdelete(keys);
1042
+ }
1043
+ async clearExpiredApprovals() {
1044
+ this.ensureInitialized();
1045
+ const now = Date.now();
1046
+ const keys = await this.storage.keys("*");
1047
+ const values = await this.storage.mget(keys);
1048
+ const keysToDelete = [];
1049
+ for (let i = 0; i < keys.length; i++) {
1050
+ const approval = this.parseRecord(values[i]);
1051
+ if (approval && approval.expiresAt && approval.expiresAt <= now) {
1052
+ keysToDelete.push(keys[i]);
1053
+ }
1054
+ }
1055
+ if (keysToDelete.length > 0) {
1056
+ return await this.storage.mdelete(keysToDelete);
1057
+ }
1058
+ return 0;
1059
+ }
1060
+ async getStats() {
1061
+ this.ensureInitialized();
1062
+ const byScope = {
1063
+ ["session" /* SESSION */]: 0,
1064
+ ["user" /* USER */]: 0,
1065
+ ["time_limited" /* TIME_LIMITED */]: 0,
1066
+ ["tool_specific" /* TOOL_SPECIFIC */]: 0,
1067
+ ["context_specific" /* CONTEXT_SPECIFIC */]: 0
1068
+ };
1069
+ const byState = {
1070
+ ["pending" /* PENDING */]: 0,
1071
+ ["approved" /* APPROVED */]: 0,
1072
+ ["denied" /* DENIED */]: 0,
1073
+ ["expired" /* EXPIRED */]: 0
1074
+ };
1075
+ const keys = await this.storage.keys("*");
1076
+ const values = await this.storage.mget(keys);
1077
+ let total = 0;
1078
+ for (const value of values) {
1079
+ const approval = this.parseRecord(value);
1080
+ if (approval) {
1081
+ total++;
1082
+ byScope[approval.scope]++;
1083
+ byState[approval.state]++;
1084
+ }
1085
+ }
1086
+ return {
1087
+ totalApprovals: total,
1088
+ byScope,
1089
+ byState
1090
+ };
1091
+ }
1092
+ async close() {
1093
+ if (this.cleanupInterval) {
1094
+ clearInterval(this.cleanupInterval);
1095
+ this.cleanupInterval = void 0;
1096
+ }
1097
+ if (this.ownedStorage && this.storage) {
1098
+ await this.storage.root.disconnect();
1099
+ }
1100
+ this.initialized = false;
1084
1101
  }
1085
1102
  };
1086
- __decorateClass([
1087
- ToolHook.Will("execute", { priority: 100 })
1088
- ], ApprovalCheckPlugin.prototype, "checkApproval", 1);
1089
- ApprovalCheckPlugin = __decorateClass([
1090
- Plugin({
1091
- name: "approval:check",
1092
- description: "Checks tool approval state before execution"
1103
+ ApprovalStorageStore = __decorateClass([
1104
+ Provider3({
1105
+ name: "provider:approval:store:storage",
1106
+ description: "Storage-backed approval store (supports Memory, Redis, Vercel KV, Upstash)",
1107
+ scope: ProviderScope3.GLOBAL
1093
1108
  })
1094
- ], ApprovalCheckPlugin);
1109
+ ], ApprovalStorageStore);
1110
+ function createApprovalMemoryStore(options = {}) {
1111
+ const memoryStorage = createMemoryStorage2();
1112
+ return new ApprovalStorageStore({
1113
+ ...options,
1114
+ storageInstance: memoryStorage
1115
+ });
1116
+ }
1095
1117
 
1096
1118
  // plugins/plugin-approval/src/approval.plugin.ts
1097
1119
  var ApprovalPlugin = class extends DynamicPlugin2 {
@@ -1103,12 +1125,6 @@ var ApprovalPlugin = class extends DynamicPlugin2 {
1103
1125
  ...options
1104
1126
  };
1105
1127
  }
1106
- /**
1107
- * Get plugin metadata including nested plugins.
1108
- */
1109
- static getPluginMetadata(_options) {
1110
- return { plugins: [ApprovalCheckPlugin] };
1111
- }
1112
1128
  };
1113
1129
  __publicField(ApprovalPlugin, "defaultOptions", {
1114
1130
  namespace: "approval",
@@ -1164,8 +1180,8 @@ __publicField(ApprovalPlugin, "dynamicProviders", (options) => {
1164
1180
  scope: ProviderScope4.CONTEXT,
1165
1181
  inject: () => [ApprovalStoreToken, FRONTMCP_CONTEXT],
1166
1182
  useFactory: (store, ctx) => {
1167
- const userId = ctx.authInfo?.extra?.["userId"] ?? ctx.authInfo?.extra?.["sub"] ?? ctx.authInfo?.clientId;
1168
- return createApprovalService(store, ctx.sessionId, userId);
1183
+ const { sessionId, userId } = resolveApprovalIdentity(ctx);
1184
+ return createApprovalService(store, sessionId, userId);
1169
1185
  }
1170
1186
  });
1171
1187
  return providers;
@@ -1174,6 +1190,7 @@ ApprovalPlugin = __decorateClass([
1174
1190
  Plugin2({
1175
1191
  name: "approval",
1176
1192
  description: "Tool authorization workflow with PKCE webhook security",
1193
+ plugins: [ApprovalCheckPlugin],
1177
1194
  contextExtensions: [
1178
1195
  {
1179
1196
  property: "approval",