@frontmcp/plugin-approval 1.8.0 → 1.8.2

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/index.js CHANGED
@@ -78,9 +78,34 @@ module.exports = __toCommonJS(index_exports);
78
78
  // plugins/plugin-approval/src/approval.plugin.ts
79
79
  var import_sdk5 = require("@frontmcp/sdk");
80
80
 
81
- // plugins/plugin-approval/src/stores/approval-storage.store.ts
82
- var import_sdk = require("@frontmcp/sdk");
81
+ // plugins/plugin-approval/src/approval.identity.ts
83
82
  var import_utils = require("@frontmcp/utils");
83
+ function nonEmptyString(value) {
84
+ return typeof value === "string" && value.length > 0 ? value : void 0;
85
+ }
86
+ function resolveApprovalIdentity(ctx) {
87
+ const extra = ctx?.authInfo?.extra;
88
+ const userId = nonEmptyString(extra?.["userId"]) ?? nonEmptyString(extra?.["sub"]) ?? nonEmptyString(ctx?.authInfo?.clientId);
89
+ const sessionId = nonEmptyString(ctx?.verifiedSessionId);
90
+ if (sessionId) {
91
+ return { sessionId, userId };
92
+ }
93
+ return { sessionId: userId ? `stateless-user:${userId}` : `unidentified:${(0, import_utils.randomUUID)()}`, userId };
94
+ }
95
+
96
+ // plugins/plugin-approval/src/approval.symbols.ts
97
+ var ApprovalStoreToken = /* @__PURE__ */ Symbol.for(
98
+ "plugin:approval:store"
99
+ );
100
+ var ApprovalServiceToken = /* @__PURE__ */ Symbol.for(
101
+ "plugin:approval:service"
102
+ );
103
+ var ChallengeServiceToken = /* @__PURE__ */ Symbol.for(
104
+ "plugin:approval:challenge-service"
105
+ );
106
+
107
+ // plugins/plugin-approval/src/hooks/approval-check.hook.ts
108
+ var import_sdk = require("@frontmcp/sdk");
84
109
 
85
110
  // plugins/plugin-approval/src/approval/types.ts
86
111
  var ApprovalScope = /* @__PURE__ */ ((ApprovalScope2) => {
@@ -445,266 +470,122 @@ var ChallengeValidationError = class extends ApprovalError {
445
470
  }
446
471
  };
447
472
 
448
- // plugins/plugin-approval/src/stores/approval-storage.store.ts
449
- function escapePattern(str) {
450
- return str.replace(/[*?[\]\\]/g, "\\$&");
451
- }
452
- var ApprovalStorageStore = class {
453
- storage;
454
- options;
455
- cleanupInterval;
456
- initialized = false;
457
- ownedStorage = false;
458
- constructor(options = {}) {
459
- this.options = {
460
- storage: options.storage ?? { type: "auto" },
461
- storageInstance: options.storageInstance,
462
- namespace: options.namespace ?? "approval",
463
- cleanupIntervalSeconds: options.cleanupIntervalSeconds ?? 60
464
- };
465
- }
466
- /**
467
- * Initialize the storage connection.
468
- */
469
- async initialize() {
470
- if (this.initialized) {
473
+ // plugins/plugin-approval/src/hooks/approval-check.hook.ts
474
+ var passedApprovalStores = /* @__PURE__ */ new WeakMap();
475
+ var ApprovalCheckPlugin = class extends import_sdk.DynamicPlugin {
476
+ async checkApproval(flowCtx) {
477
+ const { tool, toolContext } = flowCtx.state;
478
+ if (!tool || !toolContext) return;
479
+ const metadata = tool.metadata;
480
+ const approvalConfig = this.resolveApprovalConfig(
481
+ metadata["approval"]
482
+ );
483
+ if (!approvalConfig.required) {
471
484
  return;
472
485
  }
473
- if (this.options.storageInstance) {
474
- this.storage = this.options.storageInstance.namespace(this.options.namespace);
475
- this.ownedStorage = false;
476
- } else {
477
- const rootStorage = await (0, import_utils.createStorage)(this.options.storage);
478
- this.storage = rootStorage.namespace(this.options.namespace);
479
- this.ownedStorage = true;
480
- }
481
- if (this.options.cleanupIntervalSeconds > 0) {
482
- this.cleanupInterval = setInterval(() => {
483
- void this.clearExpiredApprovals();
484
- }, this.options.cleanupIntervalSeconds * 1e3);
485
- this.cleanupInterval.unref?.();
486
+ if (approvalConfig.skipApproval) {
487
+ return;
486
488
  }
487
- this.initialized = true;
489
+ const approvalStore = this.get(ApprovalStoreToken);
490
+ const passedStores = passedApprovalStores.get(toolContext) ?? /* @__PURE__ */ new WeakSet();
491
+ if (passedStores.has(approvalStore)) return;
492
+ await this.enforceApproval(flowCtx, tool, toolContext, approvalConfig, approvalStore);
493
+ passedStores.add(approvalStore);
494
+ passedApprovalStores.set(toolContext, passedStores);
488
495
  }
489
- ensureInitialized() {
490
- if (!this.initialized) {
491
- throw new Error("ApprovalStorageStore not initialized. Call initialize() first.");
496
+ async enforceApproval(flowCtx, tool, toolContext, approvalConfig, approvalStore) {
497
+ const { sessionId, userId } = resolveApprovalIdentity(toolContext.tryGetContext?.());
498
+ const approval = await approvalStore.getApproval(tool.fullName, sessionId, userId);
499
+ if (approval?.state === "denied" /* DENIED */) {
500
+ throw new ApprovalRequiredError({
501
+ toolId: tool.fullName,
502
+ state: "denied",
503
+ message: `Tool "${tool.fullName}" execution denied.`
504
+ });
492
505
  }
493
- }
494
- buildKey(toolId, sessionId, userId, context) {
495
- const parts = [toolId];
496
- if (sessionId) parts.push(`session:${sessionId}`);
497
- if (userId) parts.push(`user:${userId}`);
498
- if (context) parts.push(`ctx:${context.type}:${context.identifier}`);
499
- return parts.join(":");
500
- }
501
- parseRecord(value) {
502
- if (!value) return void 0;
503
- try {
504
- const parsed = JSON.parse(value);
505
- const result = approvalRecordSchema.safeParse(parsed);
506
- if (!result.success) {
507
- return void 0;
508
- }
509
- return result.data;
510
- } catch {
511
- return void 0;
506
+ if (this.isPreApprovedContext(approvalConfig, this.getCurrentContext(flowCtx))) {
507
+ return;
512
508
  }
513
- }
514
- isExpired(approval) {
515
- return approval.expiresAt !== void 0 && Date.now() > approval.expiresAt;
516
- }
517
- async getApproval(toolId, sessionId, userId) {
518
- this.ensureInitialized();
519
- const sessionKey = this.buildKey(toolId, sessionId);
520
- const sessionValue = await this.storage.get(sessionKey);
521
- const sessionApproval = this.parseRecord(sessionValue);
522
- if (sessionApproval && !this.isExpired(sessionApproval)) {
523
- return sessionApproval;
509
+ if (approvalConfig.alwaysPrompt) {
510
+ await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
511
+ return;
524
512
  }
525
- if (userId) {
526
- const userKey = this.buildKey(toolId, void 0, userId);
527
- const userValue = await this.storage.get(userKey);
528
- const userApproval = this.parseRecord(userValue);
529
- if (userApproval && !this.isExpired(userApproval)) {
530
- return userApproval;
513
+ if (approval?.state === "approved" /* APPROVED */) {
514
+ if (!this.isExpired(approval)) {
515
+ return;
531
516
  }
532
517
  }
533
- return void 0;
518
+ await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
534
519
  }
535
- async queryApprovals(query) {
536
- this.ensureInitialized();
537
- const results = [];
538
- const pattern = query.toolId ? `${query.toolId}:*` : "*";
539
- const keys = await this.storage.keys(pattern);
540
- const values = await this.storage.mget(keys);
541
- for (const value of values) {
542
- const approval = this.parseRecord(value);
543
- if (!approval) continue;
544
- if (!query.includeExpired && this.isExpired(approval)) {
545
- continue;
546
- }
547
- if (query.toolId && approval.toolId !== query.toolId) continue;
548
- if (query.toolIds && !query.toolIds.includes(approval.toolId)) continue;
549
- if (query.scope && approval.scope !== query.scope) continue;
550
- if (query.scopes && !query.scopes.includes(approval.scope)) continue;
551
- if (query.state && approval.state !== query.state) continue;
552
- if (query.states && !query.states.includes(approval.state)) continue;
553
- if (query.sessionId && approval.sessionId !== query.sessionId) continue;
554
- if (query.userId && approval.userId !== query.userId) continue;
555
- if (query.context) {
556
- if (!approval.context || approval.context.type !== query.context.type || approval.context.identifier !== query.context.identifier) {
557
- continue;
558
- }
559
- }
560
- results.push(approval);
520
+ resolveApprovalConfig(config) {
521
+ if (config === true) {
522
+ return { required: true, defaultScope: "session" /* SESSION */ };
561
523
  }
562
- return results;
563
- }
564
- async grantApproval(options) {
565
- this.ensureInitialized();
566
- const now = Date.now();
567
- const expiresAt = options.ttlMs ? now + options.ttlMs : void 0;
568
- const grantedBy = normalizeGrantor(options.grantedBy);
569
- const record = {
570
- toolId: options.toolId,
571
- state: "approved" /* APPROVED */,
572
- scope: options.scope,
573
- grantedAt: now,
574
- expiresAt,
575
- ttlMs: options.ttlMs,
576
- sessionId: options.sessionId,
577
- userId: options.userId,
578
- context: options.context,
579
- grantedBy,
580
- reason: options.reason,
581
- metadata: options.metadata
524
+ if (config === false || config === void 0) {
525
+ return { required: false };
526
+ }
527
+ return {
528
+ ...config,
529
+ required: config.required ?? true,
530
+ defaultScope: config.defaultScope ?? "session" /* SESSION */
582
531
  };
583
- const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
584
- const ttlSeconds = options.ttlMs ? Math.ceil(options.ttlMs / 1e3) : void 0;
585
- await this.storage.set(key, JSON.stringify(record), { ttlSeconds });
586
- return record;
587
532
  }
588
- async revokeApproval(options) {
589
- this.ensureInitialized();
590
- const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
591
- const exists = await this.storage.exists(key);
592
- if (exists) {
593
- await this.storage.delete(key);
594
- return true;
595
- }
596
- return false;
533
+ isExpired(approval) {
534
+ if (!approval.expiresAt) return false;
535
+ return Date.now() > approval.expiresAt;
597
536
  }
598
- async isApproved(toolId, sessionId, userId, context) {
599
- this.ensureInitialized();
600
- if (context) {
601
- const contextKey = this.buildKey(toolId, sessionId, userId, context);
602
- const contextValue = await this.storage.get(contextKey);
603
- const contextApproval = this.parseRecord(contextValue);
604
- if (contextApproval && contextApproval.state === "approved" /* APPROVED */ && !this.isExpired(contextApproval)) {
605
- return true;
606
- }
607
- }
608
- const sessionKey = this.buildKey(toolId, sessionId);
609
- const sessionValue = await this.storage.get(sessionKey);
610
- const sessionApproval = this.parseRecord(sessionValue);
611
- if (sessionApproval && sessionApproval.state === "approved" /* APPROVED */ && !this.isExpired(sessionApproval)) {
612
- return true;
613
- }
614
- if (userId) {
615
- const userKey = this.buildKey(toolId, void 0, userId);
616
- const userValue = await this.storage.get(userKey);
617
- const userApproval = this.parseRecord(userValue);
618
- if (userApproval && userApproval.state === "approved" /* APPROVED */ && !this.isExpired(userApproval)) {
619
- return true;
620
- }
621
- }
622
- return false;
537
+ /**
538
+ * The context this call is running in, as established by the SESSION (GHSA-r848-p7wf-96rc).
539
+ *
540
+ * Deliberately ignores `toolContext.input.context`. The only consumer of this value is
541
+ * `isPreApprovedContext`, which skips the approval prompt outright, so reading it from the
542
+ * arguments of the call being gated let a caller name its own pre-approved context and walk
543
+ * straight past the gate. Only `authInfo.extra.approvalContext`, which the server sets
544
+ * while authenticating, can say what context a call belongs to.
545
+ */
546
+ getCurrentContext(flowCtx) {
547
+ const ctx = flowCtx.state.toolContext?.tryGetContext?.();
548
+ const sessionContext = ctx?.authInfo?.extra?.["approvalContext"];
549
+ return this.isApprovalContext(sessionContext) ? sessionContext : void 0;
623
550
  }
624
- async clearSessionApprovals(sessionId) {
625
- this.ensureInitialized();
626
- const escapedSessionId = escapePattern(sessionId);
627
- const pattern = `*:session:${escapedSessionId}*`;
628
- const keys = await this.storage.keys(pattern);
629
- if (keys.length === 0) {
630
- return 0;
631
- }
632
- return await this.storage.mdelete(keys);
551
+ isApprovalContext(value) {
552
+ return typeof value === "object" && value !== null && "type" in value && "identifier" in value && typeof value.type === "string" && typeof value.identifier === "string";
633
553
  }
634
- async clearExpiredApprovals() {
635
- this.ensureInitialized();
636
- const now = Date.now();
637
- const keys = await this.storage.keys("*");
638
- const values = await this.storage.mget(keys);
639
- const keysToDelete = [];
640
- for (let i = 0; i < keys.length; i++) {
641
- const approval = this.parseRecord(values[i]);
642
- if (approval && approval.expiresAt && approval.expiresAt <= now) {
643
- keysToDelete.push(keys[i]);
644
- }
645
- }
646
- if (keysToDelete.length > 0) {
647
- return await this.storage.mdelete(keysToDelete);
554
+ isPreApprovedContext(config, currentContext) {
555
+ if (!currentContext || !config.preApprovedContexts?.length) {
556
+ return false;
648
557
  }
649
- return 0;
558
+ return config.preApprovedContexts.some(
559
+ (preApproved) => preApproved.type === currentContext.type && preApproved.identifier === currentContext.identifier
560
+ );
650
561
  }
651
- async getStats() {
652
- this.ensureInitialized();
653
- const byScope = {
654
- ["session" /* SESSION */]: 0,
655
- ["user" /* USER */]: 0,
656
- ["time_limited" /* TIME_LIMITED */]: 0,
657
- ["tool_specific" /* TOOL_SPECIFIC */]: 0,
658
- ["context_specific" /* CONTEXT_SPECIFIC */]: 0
659
- };
660
- const byState = {
661
- ["pending" /* PENDING */]: 0,
662
- ["approved" /* APPROVED */]: 0,
663
- ["denied" /* DENIED */]: 0,
664
- ["expired" /* EXPIRED */]: 0
665
- };
666
- const keys = await this.storage.keys("*");
667
- const values = await this.storage.mget(keys);
668
- let total = 0;
669
- for (const value of values) {
670
- const approval = this.parseRecord(value);
671
- if (approval) {
672
- total++;
673
- byScope[approval.scope]++;
674
- byState[approval.state]++;
562
+ async handleApprovalRequired(flowCtx, config, existingApproval) {
563
+ const { tool } = flowCtx.state;
564
+ const message = config.approvalMessage ?? `Tool "${tool?.fullName}" requires approval to execute. Allow?`;
565
+ const isExpiredApproval = existingApproval ? this.isExpired(existingApproval) : false;
566
+ throw new ApprovalRequiredError({
567
+ toolId: tool?.fullName ?? "unknown",
568
+ state: isExpiredApproval ? "expired" : "pending",
569
+ message,
570
+ approvalOptions: {
571
+ allowedScopes: config.allowedScopes,
572
+ defaultScope: config.defaultScope,
573
+ maxTtlMs: config.maxTtlMs,
574
+ category: config.category,
575
+ riskLevel: config.riskLevel
675
576
  }
676
- }
677
- return {
678
- totalApprovals: total,
679
- byScope,
680
- byState
681
- };
682
- }
683
- async close() {
684
- if (this.cleanupInterval) {
685
- clearInterval(this.cleanupInterval);
686
- this.cleanupInterval = void 0;
687
- }
688
- if (this.ownedStorage && this.storage) {
689
- await this.storage.root.disconnect();
690
- }
691
- this.initialized = false;
577
+ });
692
578
  }
693
579
  };
694
- ApprovalStorageStore = __decorateClass([
695
- (0, import_sdk.Provider)({
696
- name: "provider:approval:store:storage",
697
- description: "Storage-backed approval store (supports Memory, Redis, Vercel KV, Upstash)",
698
- scope: import_sdk.ProviderScope.GLOBAL
580
+ __decorateClass([
581
+ import_sdk.ToolHook.Will("execute", { priority: 100 })
582
+ ], ApprovalCheckPlugin.prototype, "checkApproval", 1);
583
+ ApprovalCheckPlugin = __decorateClass([
584
+ (0, import_sdk.Plugin)({
585
+ name: "approval:check",
586
+ description: "Checks tool approval state before execution"
699
587
  })
700
- ], ApprovalStorageStore);
701
- function createApprovalMemoryStore(options = {}) {
702
- const memoryStorage = (0, import_utils.createMemoryStorage)();
703
- return new ApprovalStorageStore({
704
- ...options,
705
- storageInstance: memoryStorage
706
- });
707
- }
588
+ ], ApprovalCheckPlugin);
708
589
 
709
590
  // plugins/plugin-approval/src/services/approval.service.ts
710
591
  var import_sdk2 = require("@frontmcp/sdk");
@@ -1014,134 +895,269 @@ function createMemoryChallengeService(options = {}) {
1014
895
  });
1015
896
  }
1016
897
 
1017
- // plugins/plugin-approval/src/approval.symbols.ts
1018
- var ApprovalStoreToken = /* @__PURE__ */ Symbol.for(
1019
- "plugin:approval:store"
1020
- );
1021
- var ApprovalServiceToken = /* @__PURE__ */ Symbol.for(
1022
- "plugin:approval:service"
1023
- );
1024
- var ChallengeServiceToken = /* @__PURE__ */ Symbol.for(
1025
- "plugin:approval:challenge-service"
1026
- );
1027
-
1028
- // plugins/plugin-approval/src/hooks/approval-check.hook.ts
898
+ // plugins/plugin-approval/src/stores/approval-storage.store.ts
1029
899
  var import_sdk4 = require("@frontmcp/sdk");
1030
- var ApprovalCheckPlugin = class extends import_sdk4.DynamicPlugin {
1031
- async checkApproval(flowCtx) {
1032
- const { tool, toolContext } = flowCtx.state;
1033
- if (!tool || !toolContext) return;
1034
- const metadata = tool.metadata;
1035
- const approvalConfig = this.resolveApprovalConfig(
1036
- metadata["approval"]
1037
- );
1038
- if (!approvalConfig.required) {
1039
- return;
1040
- }
1041
- if (approvalConfig.skipApproval) {
1042
- return;
1043
- }
1044
- const ctx = toolContext.tryGetContext?.();
1045
- const sessionId = ctx?.sessionId ?? "unknown";
1046
- const userId = this.getStringExtra(ctx?.authInfo?.extra, "userId") ?? this.getStringExtra(ctx?.authInfo?.extra, "sub") ?? ctx?.authInfo?.clientId;
1047
- const currentContext = this.getCurrentContext(flowCtx);
1048
- if (this.isPreApprovedContext(approvalConfig, currentContext)) {
1049
- return;
1050
- }
1051
- const approvalStore = this.get(ApprovalStoreToken);
1052
- const approval = await approvalStore.getApproval(tool.fullName, sessionId, userId);
1053
- if (approvalConfig.alwaysPrompt) {
1054
- await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
900
+ var import_utils3 = require("@frontmcp/utils");
901
+ function escapePattern(str) {
902
+ return str.replace(/[*?[\]\\]/g, "\\$&");
903
+ }
904
+ var ApprovalStorageStore = class {
905
+ storage;
906
+ options;
907
+ cleanupInterval;
908
+ initialized = false;
909
+ ownedStorage = false;
910
+ constructor(options = {}) {
911
+ this.options = {
912
+ storage: options.storage ?? { type: "auto" },
913
+ storageInstance: options.storageInstance,
914
+ namespace: options.namespace ?? "approval",
915
+ cleanupIntervalSeconds: options.cleanupIntervalSeconds ?? 60
916
+ };
917
+ }
918
+ /**
919
+ * Initialize the storage connection.
920
+ */
921
+ async initialize() {
922
+ if (this.initialized) {
1055
923
  return;
1056
924
  }
1057
- if (approval?.state === "approved" /* APPROVED */) {
1058
- if (!this.isExpired(approval)) {
1059
- return;
1060
- }
925
+ if (this.options.storageInstance) {
926
+ this.storage = this.options.storageInstance.namespace(this.options.namespace);
927
+ this.ownedStorage = false;
928
+ } else {
929
+ const rootStorage = await (0, import_utils3.createStorage)(this.options.storage);
930
+ this.storage = rootStorage.namespace(this.options.namespace);
931
+ this.ownedStorage = true;
1061
932
  }
1062
- if (approval?.state === "denied" /* DENIED */) {
1063
- throw new ApprovalRequiredError({
1064
- toolId: tool.fullName,
1065
- state: "denied",
1066
- message: `Tool "${tool.fullName}" execution denied.`
1067
- });
933
+ if (this.options.cleanupIntervalSeconds > 0) {
934
+ this.cleanupInterval = setInterval(() => {
935
+ void this.clearExpiredApprovals();
936
+ }, this.options.cleanupIntervalSeconds * 1e3);
937
+ this.cleanupInterval.unref?.();
1068
938
  }
1069
- await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
939
+ this.initialized = true;
1070
940
  }
1071
- resolveApprovalConfig(config) {
1072
- if (config === true) {
1073
- return { required: true, defaultScope: "session" /* SESSION */ };
941
+ ensureInitialized() {
942
+ if (!this.initialized) {
943
+ throw new Error("ApprovalStorageStore not initialized. Call initialize() first.");
1074
944
  }
1075
- if (config === false || config === void 0) {
1076
- return { required: false };
945
+ }
946
+ buildKey(toolId, sessionId, userId, context) {
947
+ const parts = [toolId];
948
+ if (sessionId) parts.push(`session:${sessionId}`);
949
+ if (userId) parts.push(`user:${userId}`);
950
+ if (context) parts.push(`ctx:${context.type}:${context.identifier}`);
951
+ return parts.join(":");
952
+ }
953
+ parseRecord(value) {
954
+ if (!value) return void 0;
955
+ try {
956
+ const parsed = JSON.parse(value);
957
+ const result = approvalRecordSchema.safeParse(parsed);
958
+ if (!result.success) {
959
+ return void 0;
960
+ }
961
+ return result.data;
962
+ } catch {
963
+ return void 0;
1077
964
  }
1078
- return {
1079
- ...config,
1080
- required: config.required ?? true,
1081
- defaultScope: config.defaultScope ?? "session" /* SESSION */
1082
- };
1083
965
  }
1084
966
  isExpired(approval) {
1085
- if (!approval.expiresAt) return false;
1086
- return Date.now() > approval.expiresAt;
967
+ return approval.expiresAt !== void 0 && Date.now() > approval.expiresAt;
1087
968
  }
1088
969
  /**
1089
- * The context this call is running in, as established by the SESSION (GHSA-r848-p7wf-96rc).
1090
- *
1091
- * Deliberately ignores `toolContext.input.context`. The only consumer of this value is
1092
- * `isPreApprovedContext`, which skips the approval prompt outright, so reading it from the
1093
- * arguments of the call being gated let a caller name its own pre-approved context and walk
1094
- * straight past the gate. Only `authInfo.extra.approvalContext`, which the server sets
1095
- * while authenticating, can say what context a call belongs to.
970
+ * The session record, else the user record, except that a denial in either scope wins,
971
+ * so a session approval cannot mask a user-level denial.
1096
972
  */
1097
- getCurrentContext(flowCtx) {
1098
- const ctx = flowCtx.state.toolContext?.tryGetContext?.();
1099
- const sessionContext = ctx?.authInfo?.extra?.["approvalContext"];
1100
- return this.isApprovalContext(sessionContext) ? sessionContext : void 0;
973
+ async getApproval(toolId, sessionId, userId) {
974
+ this.ensureInitialized();
975
+ const keys = [this.buildKey(toolId, sessionId)];
976
+ if (userId) {
977
+ keys.push(this.buildKey(toolId, void 0, userId));
978
+ }
979
+ const activeRecords = [];
980
+ for (const key of keys) {
981
+ const record = this.parseRecord(await this.storage.get(key));
982
+ if (record && !this.isExpired(record)) {
983
+ activeRecords.push(record);
984
+ }
985
+ }
986
+ return activeRecords.find((record) => record.state === "denied" /* DENIED */) ?? activeRecords[0];
1101
987
  }
1102
- isApprovalContext(value) {
1103
- return typeof value === "object" && value !== null && "type" in value && "identifier" in value && typeof value.type === "string" && typeof value.identifier === "string";
988
+ async queryApprovals(query) {
989
+ this.ensureInitialized();
990
+ const results = [];
991
+ const pattern = query.toolId ? `${query.toolId}:*` : "*";
992
+ const keys = await this.storage.keys(pattern);
993
+ const values = await this.storage.mget(keys);
994
+ for (const value of values) {
995
+ const approval = this.parseRecord(value);
996
+ if (!approval) continue;
997
+ if (!query.includeExpired && this.isExpired(approval)) {
998
+ continue;
999
+ }
1000
+ if (query.toolId && approval.toolId !== query.toolId) continue;
1001
+ if (query.toolIds && !query.toolIds.includes(approval.toolId)) continue;
1002
+ if (query.scope && approval.scope !== query.scope) continue;
1003
+ if (query.scopes && !query.scopes.includes(approval.scope)) continue;
1004
+ if (query.state && approval.state !== query.state) continue;
1005
+ if (query.states && !query.states.includes(approval.state)) continue;
1006
+ if (query.sessionId && approval.sessionId !== query.sessionId) continue;
1007
+ if (query.userId && approval.userId !== query.userId) continue;
1008
+ if (query.context) {
1009
+ if (!approval.context || approval.context.type !== query.context.type || approval.context.identifier !== query.context.identifier) {
1010
+ continue;
1011
+ }
1012
+ }
1013
+ results.push(approval);
1014
+ }
1015
+ return results;
1104
1016
  }
1105
- getStringExtra(extra, key) {
1106
- if (!extra) return void 0;
1107
- const value = extra[key];
1108
- return typeof value === "string" ? value : void 0;
1017
+ async grantApproval(options) {
1018
+ this.ensureInitialized();
1019
+ const now = Date.now();
1020
+ const expiresAt = options.ttlMs ? now + options.ttlMs : void 0;
1021
+ const grantedBy = normalizeGrantor(options.grantedBy);
1022
+ const record = {
1023
+ toolId: options.toolId,
1024
+ state: "approved" /* APPROVED */,
1025
+ scope: options.scope,
1026
+ grantedAt: now,
1027
+ expiresAt,
1028
+ ttlMs: options.ttlMs,
1029
+ sessionId: options.sessionId,
1030
+ userId: options.userId,
1031
+ context: options.context,
1032
+ grantedBy,
1033
+ reason: options.reason,
1034
+ metadata: options.metadata
1035
+ };
1036
+ const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
1037
+ const ttlSeconds = options.ttlMs ? Math.ceil(options.ttlMs / 1e3) : void 0;
1038
+ await this.storage.set(key, JSON.stringify(record), { ttlSeconds });
1039
+ return record;
1109
1040
  }
1110
- isPreApprovedContext(config, currentContext) {
1111
- if (!currentContext || !config.preApprovedContexts?.length) {
1112
- return false;
1041
+ async revokeApproval(options) {
1042
+ this.ensureInitialized();
1043
+ const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
1044
+ const exists = await this.storage.exists(key);
1045
+ if (exists) {
1046
+ await this.storage.delete(key);
1047
+ return true;
1113
1048
  }
1114
- return config.preApprovedContexts.some(
1115
- (preApproved) => preApproved.type === currentContext.type && preApproved.identifier === currentContext.identifier
1116
- );
1049
+ return false;
1117
1050
  }
1118
- async handleApprovalRequired(flowCtx, config, existingApproval) {
1119
- const { tool } = flowCtx.state;
1120
- const message = config.approvalMessage ?? `Tool "${tool?.fullName}" requires approval to execute. Allow?`;
1121
- const isExpiredApproval = existingApproval ? this.isExpired(existingApproval) : false;
1122
- throw new ApprovalRequiredError({
1123
- toolId: tool?.fullName ?? "unknown",
1124
- state: isExpiredApproval ? "expired" : "pending",
1125
- message,
1126
- approvalOptions: {
1127
- allowedScopes: config.allowedScopes,
1128
- defaultScope: config.defaultScope,
1129
- maxTtlMs: config.maxTtlMs,
1130
- category: config.category,
1131
- riskLevel: config.riskLevel
1051
+ async isApproved(toolId, sessionId, userId, context) {
1052
+ this.ensureInitialized();
1053
+ if (context) {
1054
+ const contextKey = this.buildKey(toolId, sessionId, userId, context);
1055
+ const contextValue = await this.storage.get(contextKey);
1056
+ const contextApproval = this.parseRecord(contextValue);
1057
+ if (contextApproval && contextApproval.state === "approved" /* APPROVED */ && !this.isExpired(contextApproval)) {
1058
+ return true;
1132
1059
  }
1133
- });
1060
+ }
1061
+ const sessionKey = this.buildKey(toolId, sessionId);
1062
+ const sessionValue = await this.storage.get(sessionKey);
1063
+ const sessionApproval = this.parseRecord(sessionValue);
1064
+ if (sessionApproval && sessionApproval.state === "approved" /* APPROVED */ && !this.isExpired(sessionApproval)) {
1065
+ return true;
1066
+ }
1067
+ if (userId) {
1068
+ const userKey = this.buildKey(toolId, void 0, userId);
1069
+ const userValue = await this.storage.get(userKey);
1070
+ const userApproval = this.parseRecord(userValue);
1071
+ if (userApproval && userApproval.state === "approved" /* APPROVED */ && !this.isExpired(userApproval)) {
1072
+ return true;
1073
+ }
1074
+ }
1075
+ return false;
1076
+ }
1077
+ async clearSessionApprovals(sessionId) {
1078
+ this.ensureInitialized();
1079
+ const escapedSessionId = escapePattern(sessionId);
1080
+ const pattern = `*:session:${escapedSessionId}*`;
1081
+ const keys = await this.storage.keys(pattern);
1082
+ if (keys.length === 0) {
1083
+ return 0;
1084
+ }
1085
+ return await this.storage.mdelete(keys);
1086
+ }
1087
+ async clearExpiredApprovals() {
1088
+ this.ensureInitialized();
1089
+ const now = Date.now();
1090
+ const keys = await this.storage.keys("*");
1091
+ const values = await this.storage.mget(keys);
1092
+ const keysToDelete = [];
1093
+ for (let i = 0; i < keys.length; i++) {
1094
+ const approval = this.parseRecord(values[i]);
1095
+ if (approval && approval.expiresAt && approval.expiresAt <= now) {
1096
+ keysToDelete.push(keys[i]);
1097
+ }
1098
+ }
1099
+ if (keysToDelete.length > 0) {
1100
+ return await this.storage.mdelete(keysToDelete);
1101
+ }
1102
+ return 0;
1103
+ }
1104
+ async getStats() {
1105
+ this.ensureInitialized();
1106
+ const byScope = {
1107
+ ["session" /* SESSION */]: 0,
1108
+ ["user" /* USER */]: 0,
1109
+ ["time_limited" /* TIME_LIMITED */]: 0,
1110
+ ["tool_specific" /* TOOL_SPECIFIC */]: 0,
1111
+ ["context_specific" /* CONTEXT_SPECIFIC */]: 0
1112
+ };
1113
+ const byState = {
1114
+ ["pending" /* PENDING */]: 0,
1115
+ ["approved" /* APPROVED */]: 0,
1116
+ ["denied" /* DENIED */]: 0,
1117
+ ["expired" /* EXPIRED */]: 0
1118
+ };
1119
+ const keys = await this.storage.keys("*");
1120
+ const values = await this.storage.mget(keys);
1121
+ let total = 0;
1122
+ for (const value of values) {
1123
+ const approval = this.parseRecord(value);
1124
+ if (approval) {
1125
+ total++;
1126
+ byScope[approval.scope]++;
1127
+ byState[approval.state]++;
1128
+ }
1129
+ }
1130
+ return {
1131
+ totalApprovals: total,
1132
+ byScope,
1133
+ byState
1134
+ };
1135
+ }
1136
+ async close() {
1137
+ if (this.cleanupInterval) {
1138
+ clearInterval(this.cleanupInterval);
1139
+ this.cleanupInterval = void 0;
1140
+ }
1141
+ if (this.ownedStorage && this.storage) {
1142
+ await this.storage.root.disconnect();
1143
+ }
1144
+ this.initialized = false;
1134
1145
  }
1135
1146
  };
1136
- __decorateClass([
1137
- import_sdk4.ToolHook.Will("execute", { priority: 100 })
1138
- ], ApprovalCheckPlugin.prototype, "checkApproval", 1);
1139
- ApprovalCheckPlugin = __decorateClass([
1140
- (0, import_sdk4.Plugin)({
1141
- name: "approval:check",
1142
- description: "Checks tool approval state before execution"
1147
+ ApprovalStorageStore = __decorateClass([
1148
+ (0, import_sdk4.Provider)({
1149
+ name: "provider:approval:store:storage",
1150
+ description: "Storage-backed approval store (supports Memory, Redis, Vercel KV, Upstash)",
1151
+ scope: import_sdk4.ProviderScope.GLOBAL
1143
1152
  })
1144
- ], ApprovalCheckPlugin);
1153
+ ], ApprovalStorageStore);
1154
+ function createApprovalMemoryStore(options = {}) {
1155
+ const memoryStorage = (0, import_utils3.createMemoryStorage)();
1156
+ return new ApprovalStorageStore({
1157
+ ...options,
1158
+ storageInstance: memoryStorage
1159
+ });
1160
+ }
1145
1161
 
1146
1162
  // plugins/plugin-approval/src/approval.plugin.ts
1147
1163
  var ApprovalPlugin = class extends import_sdk5.DynamicPlugin {
@@ -1153,12 +1169,6 @@ var ApprovalPlugin = class extends import_sdk5.DynamicPlugin {
1153
1169
  ...options
1154
1170
  };
1155
1171
  }
1156
- /**
1157
- * Get plugin metadata including nested plugins.
1158
- */
1159
- static getPluginMetadata(_options) {
1160
- return { plugins: [ApprovalCheckPlugin] };
1161
- }
1162
1172
  };
1163
1173
  __publicField(ApprovalPlugin, "defaultOptions", {
1164
1174
  namespace: "approval",
@@ -1214,8 +1224,8 @@ __publicField(ApprovalPlugin, "dynamicProviders", (options) => {
1214
1224
  scope: import_sdk5.ProviderScope.CONTEXT,
1215
1225
  inject: () => [ApprovalStoreToken, import_sdk5.FRONTMCP_CONTEXT],
1216
1226
  useFactory: (store, ctx) => {
1217
- const userId = ctx.authInfo?.extra?.["userId"] ?? ctx.authInfo?.extra?.["sub"] ?? ctx.authInfo?.clientId;
1218
- return createApprovalService(store, ctx.sessionId, userId);
1227
+ const { sessionId, userId } = resolveApprovalIdentity(ctx);
1228
+ return createApprovalService(store, sessionId, userId);
1219
1229
  }
1220
1230
  });
1221
1231
  return providers;
@@ -1224,6 +1234,7 @@ ApprovalPlugin = __decorateClass([
1224
1234
  (0, import_sdk5.Plugin)({
1225
1235
  name: "approval",
1226
1236
  description: "Tool authorization workflow with PKCE webhook security",
1237
+ plugins: [ApprovalCheckPlugin],
1227
1238
  contextExtensions: [
1228
1239
  {
1229
1240
  property: "approval",