@frontmcp/plugin-approval 1.8.1 → 1.8.3

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
@@ -76,10 +76,208 @@ __export(index_exports, {
76
76
  module.exports = __toCommonJS(index_exports);
77
77
 
78
78
  // plugins/plugin-approval/src/approval.plugin.ts
79
- var import_sdk6 = require("@frontmcp/sdk");
79
+ var import_sdk5 = require("@frontmcp/sdk");
80
+
81
+ // plugins/plugin-approval/src/approval/errors.ts
82
+ var ApprovalError = class extends Error {
83
+ constructor(message) {
84
+ super(message);
85
+ this.name = "ApprovalError";
86
+ }
87
+ };
88
+ var ApprovalRequiredError = class extends ApprovalError {
89
+ constructor(details) {
90
+ super(details.message);
91
+ this.details = details;
92
+ this.name = "ApprovalRequiredError";
93
+ }
94
+ details;
95
+ /**
96
+ * Convert to a JSON-RPC compatible error structure.
97
+ */
98
+ toJsonRpcError() {
99
+ return {
100
+ code: -32600,
101
+ // Invalid Request
102
+ message: this.details.message,
103
+ data: {
104
+ type: "approval_required",
105
+ toolId: this.details.toolId,
106
+ state: this.details.state,
107
+ options: this.details.approvalOptions
108
+ }
109
+ };
110
+ }
111
+ };
112
+ var ApprovalOperationError = class extends ApprovalError {
113
+ constructor(operation, reason) {
114
+ super(`Approval ${operation} failed: ${reason}`);
115
+ this.operation = operation;
116
+ this.reason = reason;
117
+ this.name = "ApprovalOperationError";
118
+ }
119
+ operation;
120
+ reason;
121
+ /**
122
+ * Convert to a JSON-RPC compatible error structure.
123
+ */
124
+ toJsonRpcError() {
125
+ return {
126
+ code: -32603,
127
+ // Internal Error
128
+ message: "Approval operation failed",
129
+ data: {
130
+ type: "approval_operation_error",
131
+ operation: this.operation
132
+ }
133
+ };
134
+ }
135
+ };
136
+ var ApprovalScopeNotAllowedError = class extends ApprovalError {
137
+ constructor(requestedScope, allowedScopes) {
138
+ super(
139
+ `Approval scope '${requestedScope}' is not allowed for this tool. Allowed scopes: ${allowedScopes.join(", ")}`
140
+ );
141
+ this.requestedScope = requestedScope;
142
+ this.allowedScopes = allowedScopes;
143
+ this.name = "ApprovalScopeNotAllowedError";
144
+ }
145
+ requestedScope;
146
+ allowedScopes;
147
+ /**
148
+ * Convert to a JSON-RPC compatible error structure.
149
+ */
150
+ toJsonRpcError() {
151
+ return {
152
+ code: -32602,
153
+ // Invalid Params
154
+ message: this.message,
155
+ data: {
156
+ type: "approval_scope_not_allowed",
157
+ requestedScope: this.requestedScope,
158
+ allowedScopes: this.allowedScopes
159
+ }
160
+ };
161
+ }
162
+ };
163
+ var ApprovalExpiredError = class extends ApprovalError {
164
+ constructor(toolId, expiredAt) {
165
+ super(`Approval for tool '${toolId}' expired at ${new Date(expiredAt).toISOString()}`);
166
+ this.toolId = toolId;
167
+ this.expiredAt = expiredAt;
168
+ this.name = "ApprovalExpiredError";
169
+ }
170
+ toolId;
171
+ expiredAt;
172
+ /**
173
+ * Convert to a JSON-RPC compatible error structure.
174
+ */
175
+ toJsonRpcError() {
176
+ return {
177
+ code: -32600,
178
+ // Invalid Request
179
+ message: this.message,
180
+ data: {
181
+ type: "approval_expired",
182
+ toolId: this.toolId,
183
+ expiredAt: this.expiredAt
184
+ }
185
+ };
186
+ }
187
+ };
188
+ var ChallengeValidationError = class extends ApprovalError {
189
+ constructor(reason = "invalid", message) {
190
+ super(message ?? `PKCE challenge validation failed: ${reason}`);
191
+ this.reason = reason;
192
+ this.name = "ChallengeValidationError";
193
+ }
194
+ reason;
195
+ /**
196
+ * Convert to a JSON-RPC compatible error structure.
197
+ */
198
+ toJsonRpcError() {
199
+ return {
200
+ code: -32600,
201
+ // Invalid Request
202
+ message: this.message,
203
+ data: {
204
+ type: "challenge_validation_error",
205
+ reason: this.reason
206
+ }
207
+ };
208
+ }
209
+ };
210
+
211
+ // plugins/plugin-approval/src/approval/types.ts
212
+ var ApprovalScope = /* @__PURE__ */ ((ApprovalScope2) => {
213
+ ApprovalScope2["SESSION"] = "session";
214
+ ApprovalScope2["USER"] = "user";
215
+ ApprovalScope2["TIME_LIMITED"] = "time_limited";
216
+ ApprovalScope2["TOOL_SPECIFIC"] = "tool_specific";
217
+ ApprovalScope2["CONTEXT_SPECIFIC"] = "context_specific";
218
+ return ApprovalScope2;
219
+ })(ApprovalScope || {});
220
+ var ApprovalState = /* @__PURE__ */ ((ApprovalState2) => {
221
+ ApprovalState2["PENDING"] = "pending";
222
+ ApprovalState2["APPROVED"] = "approved";
223
+ ApprovalState2["DENIED"] = "denied";
224
+ ApprovalState2["EXPIRED"] = "expired";
225
+ return ApprovalState2;
226
+ })(ApprovalState || {});
227
+
228
+ // plugins/plugin-approval/src/approval/policy.ts
229
+ function resolveApprovalRequirement(config) {
230
+ if (config === true) {
231
+ return { required: true, defaultScope: "session" /* SESSION */ };
232
+ }
233
+ if (config === false || config === void 0) {
234
+ return { required: false };
235
+ }
236
+ return {
237
+ ...config,
238
+ required: config.required ?? true,
239
+ defaultScope: config.defaultScope ?? "session" /* SESSION */
240
+ };
241
+ }
242
+ function effectiveExpiresAt(record, requirement) {
243
+ const capped = requirement.maxTtlMs !== void 0 ? record.grantedAt + requirement.maxTtlMs : void 0;
244
+ if (record.expiresAt === void 0) return capped;
245
+ return capped === void 0 ? record.expiresAt : Math.min(record.expiresAt, capped);
246
+ }
247
+ function isApprovalExpired(record, requirement, now) {
248
+ const expiresAt = effectiveExpiresAt(record, requirement);
249
+ return expiresAt !== void 0 && now > expiresAt;
250
+ }
251
+ function isApprovalUsable(record, requirement, now) {
252
+ if (record.state !== "approved" /* APPROVED */) return false;
253
+ if (requirement.allowedScopes?.length && !requirement.allowedScopes.includes(record.scope)) return false;
254
+ return !isApprovalExpired(record, requirement, now);
255
+ }
256
+ function assertValidTtl(ttlMs) {
257
+ if (ttlMs === void 0) return;
258
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
259
+ throw new ApprovalOperationError("grant", `ttlMs must be a positive number of milliseconds, got ${ttlMs}`);
260
+ }
261
+ }
262
+ function checkGrantAgainstPolicy(toolId, scope, ttlMs, requirement) {
263
+ assertValidTtl(ttlMs);
264
+ if (!requirement) return ttlMs;
265
+ if (requirement.allowedScopes?.length && !requirement.allowedScopes.includes(scope)) {
266
+ throw new ApprovalScopeNotAllowedError(scope, requirement.allowedScopes);
267
+ }
268
+ const { maxTtlMs } = requirement;
269
+ if (maxTtlMs === void 0) return ttlMs;
270
+ if (ttlMs === void 0) return maxTtlMs;
271
+ if (ttlMs > maxTtlMs) {
272
+ throw new ApprovalOperationError(
273
+ "grant",
274
+ `ttlMs ${ttlMs} exceeds the maximum of ${maxTtlMs} ms that tool "${toolId}" allows`
275
+ );
276
+ }
277
+ return ttlMs;
278
+ }
80
279
 
81
280
  // plugins/plugin-approval/src/approval.identity.ts
82
- var import_sdk = require("@frontmcp/sdk");
83
281
  var import_utils = require("@frontmcp/utils");
84
282
  function nonEmptyString(value) {
85
283
  return typeof value === "string" && value.length > 0 ? value : void 0;
@@ -87,8 +285,8 @@ function nonEmptyString(value) {
87
285
  function resolveApprovalIdentity(ctx) {
88
286
  const extra = ctx?.authInfo?.extra;
89
287
  const userId = nonEmptyString(extra?.["userId"]) ?? nonEmptyString(extra?.["sub"]) ?? nonEmptyString(ctx?.authInfo?.clientId);
90
- const sessionId = nonEmptyString(ctx?.sessionId);
91
- if (sessionId && sessionId !== import_sdk.STATELESS_SESSION_ID) {
288
+ const sessionId = nonEmptyString(ctx?.verifiedSessionId);
289
+ if (sessionId) {
92
290
  return { sessionId, userId };
93
291
  }
94
292
  return { sessionId: userId ? `stateless-user:${userId}` : `unidentified:${(0, import_utils.randomUUID)()}`, userId };
@@ -106,24 +304,7 @@ var ChallengeServiceToken = /* @__PURE__ */ Symbol.for(
106
304
  );
107
305
 
108
306
  // plugins/plugin-approval/src/hooks/approval-check.hook.ts
109
- var import_sdk2 = require("@frontmcp/sdk");
110
-
111
- // plugins/plugin-approval/src/approval/types.ts
112
- var ApprovalScope = /* @__PURE__ */ ((ApprovalScope2) => {
113
- ApprovalScope2["SESSION"] = "session";
114
- ApprovalScope2["USER"] = "user";
115
- ApprovalScope2["TIME_LIMITED"] = "time_limited";
116
- ApprovalScope2["TOOL_SPECIFIC"] = "tool_specific";
117
- ApprovalScope2["CONTEXT_SPECIFIC"] = "context_specific";
118
- return ApprovalScope2;
119
- })(ApprovalScope || {});
120
- var ApprovalState = /* @__PURE__ */ ((ApprovalState2) => {
121
- ApprovalState2["PENDING"] = "pending";
122
- ApprovalState2["APPROVED"] = "approved";
123
- ApprovalState2["DENIED"] = "denied";
124
- ApprovalState2["EXPIRED"] = "expired";
125
- return ApprovalState2;
126
- })(ApprovalState || {});
307
+ var import_sdk = require("@frontmcp/sdk");
127
308
 
128
309
  // plugins/plugin-approval/src/approval/schemas.ts
129
310
  var import_lazy_zod = require("@frontmcp/lazy-zod");
@@ -341,144 +522,14 @@ function isApiGrantor(grantor) {
341
522
  return grantor.source === "api" || grantor.source === "oauth";
342
523
  }
343
524
 
344
- // plugins/plugin-approval/src/approval/errors.ts
345
- var ApprovalError = class extends Error {
346
- constructor(message) {
347
- super(message);
348
- this.name = "ApprovalError";
349
- }
350
- };
351
- var ApprovalRequiredError = class extends ApprovalError {
352
- constructor(details) {
353
- super(details.message);
354
- this.details = details;
355
- this.name = "ApprovalRequiredError";
356
- }
357
- details;
358
- /**
359
- * Convert to a JSON-RPC compatible error structure.
360
- */
361
- toJsonRpcError() {
362
- return {
363
- code: -32600,
364
- // Invalid Request
365
- message: this.details.message,
366
- data: {
367
- type: "approval_required",
368
- toolId: this.details.toolId,
369
- state: this.details.state,
370
- options: this.details.approvalOptions
371
- }
372
- };
373
- }
374
- };
375
- var ApprovalOperationError = class extends ApprovalError {
376
- constructor(operation, reason) {
377
- super(`Approval ${operation} failed: ${reason}`);
378
- this.operation = operation;
379
- this.reason = reason;
380
- this.name = "ApprovalOperationError";
381
- }
382
- operation;
383
- reason;
384
- /**
385
- * Convert to a JSON-RPC compatible error structure.
386
- */
387
- toJsonRpcError() {
388
- return {
389
- code: -32603,
390
- // Internal Error
391
- message: "Approval operation failed",
392
- data: {
393
- type: "approval_operation_error",
394
- operation: this.operation
395
- }
396
- };
397
- }
398
- };
399
- var ApprovalScopeNotAllowedError = class extends ApprovalError {
400
- constructor(requestedScope, allowedScopes) {
401
- super(
402
- `Approval scope '${requestedScope}' is not allowed for this tool. Allowed scopes: ${allowedScopes.join(", ")}`
403
- );
404
- this.requestedScope = requestedScope;
405
- this.allowedScopes = allowedScopes;
406
- this.name = "ApprovalScopeNotAllowedError";
407
- }
408
- requestedScope;
409
- allowedScopes;
410
- /**
411
- * Convert to a JSON-RPC compatible error structure.
412
- */
413
- toJsonRpcError() {
414
- return {
415
- code: -32602,
416
- // Invalid Params
417
- message: this.message,
418
- data: {
419
- type: "approval_scope_not_allowed",
420
- requestedScope: this.requestedScope,
421
- allowedScopes: this.allowedScopes
422
- }
423
- };
424
- }
425
- };
426
- var ApprovalExpiredError = class extends ApprovalError {
427
- constructor(toolId, expiredAt) {
428
- super(`Approval for tool '${toolId}' expired at ${new Date(expiredAt).toISOString()}`);
429
- this.toolId = toolId;
430
- this.expiredAt = expiredAt;
431
- this.name = "ApprovalExpiredError";
432
- }
433
- toolId;
434
- expiredAt;
435
- /**
436
- * Convert to a JSON-RPC compatible error structure.
437
- */
438
- toJsonRpcError() {
439
- return {
440
- code: -32600,
441
- // Invalid Request
442
- message: this.message,
443
- data: {
444
- type: "approval_expired",
445
- toolId: this.toolId,
446
- expiredAt: this.expiredAt
447
- }
448
- };
449
- }
450
- };
451
- var ChallengeValidationError = class extends ApprovalError {
452
- constructor(reason = "invalid", message) {
453
- super(message ?? `PKCE challenge validation failed: ${reason}`);
454
- this.reason = reason;
455
- this.name = "ChallengeValidationError";
456
- }
457
- reason;
458
- /**
459
- * Convert to a JSON-RPC compatible error structure.
460
- */
461
- toJsonRpcError() {
462
- return {
463
- code: -32600,
464
- // Invalid Request
465
- message: this.message,
466
- data: {
467
- type: "challenge_validation_error",
468
- reason: this.reason
469
- }
470
- };
471
- }
472
- };
473
-
474
525
  // plugins/plugin-approval/src/hooks/approval-check.hook.ts
475
526
  var passedApprovalStores = /* @__PURE__ */ new WeakMap();
476
- var ApprovalCheckPlugin = class extends import_sdk2.DynamicPlugin {
527
+ var ApprovalCheckPlugin = class extends import_sdk.DynamicPlugin {
477
528
  async checkApproval(flowCtx) {
478
529
  const { tool, toolContext } = flowCtx.state;
479
530
  if (!tool || !toolContext) return;
480
531
  const metadata = tool.metadata;
481
- const approvalConfig = this.resolveApprovalConfig(
532
+ const approvalConfig = resolveApprovalRequirement(
482
533
  metadata["approval"]
483
534
  );
484
535
  if (!approvalConfig.required) {
@@ -496,44 +547,43 @@ var ApprovalCheckPlugin = class extends import_sdk2.DynamicPlugin {
496
547
  }
497
548
  async enforceApproval(flowCtx, tool, toolContext, approvalConfig, approvalStore) {
498
549
  const { sessionId, userId } = resolveApprovalIdentity(toolContext.tryGetContext?.());
499
- const approval = await approvalStore.getApproval(tool.fullName, sessionId, userId);
500
- if (approval?.state === "denied" /* DENIED */) {
550
+ const currentContext = this.getCurrentContext(flowCtx);
551
+ const records = await this.readApprovals(approvalStore, tool.fullName, sessionId, userId, currentContext);
552
+ if (records.some((record) => record.state === "denied" /* DENIED */)) {
501
553
  throw new ApprovalRequiredError({
502
554
  toolId: tool.fullName,
503
555
  state: "denied",
504
556
  message: `Tool "${tool.fullName}" execution denied.`
505
557
  });
506
558
  }
507
- if (this.isPreApprovedContext(approvalConfig, this.getCurrentContext(flowCtx))) {
559
+ if (this.isPreApprovedContext(approvalConfig, currentContext)) {
508
560
  return;
509
561
  }
562
+ const approved = records.filter((record) => record.state === "approved" /* APPROVED */);
510
563
  if (approvalConfig.alwaysPrompt) {
511
- await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
564
+ await this.handleApprovalRequired(flowCtx, approvalConfig, approved[0]);
512
565
  return;
513
566
  }
514
- if (approval?.state === "approved" /* APPROVED */) {
515
- if (!this.isExpired(approval)) {
516
- return;
517
- }
567
+ const now = Date.now();
568
+ if (approved.some((record) => isApprovalUsable(record, approvalConfig, now))) {
569
+ return;
518
570
  }
519
- await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
571
+ await this.handleApprovalRequired(
572
+ flowCtx,
573
+ approvalConfig,
574
+ approved.find((record) => isApprovalExpired(record, approvalConfig, now)) ?? approved[0]
575
+ );
520
576
  }
521
- resolveApprovalConfig(config) {
522
- if (config === true) {
523
- return { required: true, defaultScope: "session" /* SESSION */ };
524
- }
525
- if (config === false || config === void 0) {
526
- return { required: false };
577
+ /**
578
+ * The caller's approvals and denials of the tool, from the context it runs in too. Stores
579
+ * without `getApprovals()` give the one record `getApproval()` picks.
580
+ */
581
+ async readApprovals(store, toolId, sessionId, userId, context) {
582
+ if (store.getApprovals) {
583
+ return store.getApprovals(toolId, sessionId, userId, context);
527
584
  }
528
- return {
529
- ...config,
530
- required: config.required ?? true,
531
- defaultScope: config.defaultScope ?? "session" /* SESSION */
532
- };
533
- }
534
- isExpired(approval) {
535
- if (!approval.expiresAt) return false;
536
- return Date.now() > approval.expiresAt;
585
+ const record = await store.getApproval(toolId, sessionId, userId, context);
586
+ return record ? [record] : [];
537
587
  }
538
588
  /**
539
589
  * The context this call is running in, as established by the SESSION (GHSA-r848-p7wf-96rc).
@@ -563,7 +613,7 @@ var ApprovalCheckPlugin = class extends import_sdk2.DynamicPlugin {
563
613
  async handleApprovalRequired(flowCtx, config, existingApproval) {
564
614
  const { tool } = flowCtx.state;
565
615
  const message = config.approvalMessage ?? `Tool "${tool?.fullName}" requires approval to execute. Allow?`;
566
- const isExpiredApproval = existingApproval ? this.isExpired(existingApproval) : false;
616
+ const isExpiredApproval = existingApproval ? isApprovalExpired(existingApproval, config, Date.now()) : false;
567
617
  throw new ApprovalRequiredError({
568
618
  toolId: tool?.fullName ?? "unknown",
569
619
  state: isExpiredApproval ? "expired" : "pending",
@@ -579,26 +629,34 @@ var ApprovalCheckPlugin = class extends import_sdk2.DynamicPlugin {
579
629
  }
580
630
  };
581
631
  __decorateClass([
582
- import_sdk2.ToolHook.Will("execute", { priority: 100 })
632
+ import_sdk.ToolHook.Will("execute", { priority: 100, appliesTo: "uncovered-apps" })
583
633
  ], ApprovalCheckPlugin.prototype, "checkApproval", 1);
584
634
  ApprovalCheckPlugin = __decorateClass([
585
- (0, import_sdk2.Plugin)({
635
+ (0, import_sdk.Plugin)({
586
636
  name: "approval:check",
587
- description: "Checks tool approval state before execution"
637
+ description: "Checks tool approval state before execution",
638
+ // A server where a tool or agent declares `approval` and this gate does not reach it refuses to start.
639
+ enforcesMetadata: ["approval"]
588
640
  })
589
641
  ], ApprovalCheckPlugin);
590
642
 
591
643
  // plugins/plugin-approval/src/services/approval.service.ts
592
- var import_sdk3 = require("@frontmcp/sdk");
644
+ var import_sdk2 = require("@frontmcp/sdk");
593
645
  var ApprovalService = class {
594
- constructor(store, sessionId, userId) {
646
+ constructor(store, sessionId, userId, requirementOf = () => void 0) {
595
647
  this.store = store;
596
648
  this.sessionId = sessionId;
597
649
  this.userId = userId;
650
+ this.requirementOf = requirementOf;
598
651
  }
599
652
  store;
600
653
  sessionId;
601
654
  userId;
655
+ requirementOf;
656
+ /** Checks a grant against the tool's `allowedScopes` and `maxTtlMs`; returns the ttl to store. */
657
+ checkGrant(toolId, scope, ttlMs) {
658
+ return checkGrantAgainstPolicy(toolId, scope, ttlMs, this.requirementOf(toolId));
659
+ }
602
660
  // ─────────────────────────────────────────────────────────────────────────────
603
661
  // Query Methods
604
662
  // ─────────────────────────────────────────────────────────────────────────────
@@ -606,7 +664,14 @@ var ApprovalService = class {
606
664
  * Check if a tool is approved for current session/user.
607
665
  */
608
666
  async isApproved(toolId, context) {
609
- return this.store.isApproved(toolId, this.sessionId, this.userId, context);
667
+ const requirement = this.requirementOf(toolId);
668
+ if (!requirement || !this.store.getApprovals) {
669
+ return this.store.isApproved(toolId, this.sessionId, this.userId, context);
670
+ }
671
+ const records = await this.store.getApprovals(toolId, this.sessionId, this.userId, context);
672
+ if (records.some((record) => record.state === "denied" /* DENIED */)) return false;
673
+ const now = Date.now();
674
+ return records.some((record) => isApprovalUsable(record, requirement, now));
610
675
  }
611
676
  /**
612
677
  * Get approval record for a tool.
@@ -637,14 +702,15 @@ var ApprovalService = class {
637
702
  });
638
703
  }
639
704
  /**
640
- * Query approvals with custom filters.
705
+ * Query the current caller's approvals (those of its session and those of its user) with custom
706
+ * filters. A `sessionId` or `userId` in the query narrows the result further; it never reaches
707
+ * another caller's records. Read the store directly for administrative queries.
641
708
  */
642
709
  async queryApprovals(query) {
643
- return this.store.queryApprovals({
644
- ...query,
645
- sessionId: query.sessionId ?? this.sessionId,
646
- userId: query.userId ?? this.userId
647
- });
710
+ const records = await this.store.queryApprovals(query);
711
+ return records.filter(
712
+ (record) => record.sessionId === this.sessionId || this.userId !== void 0 && record.userId === this.userId
713
+ );
648
714
  }
649
715
  // ─────────────────────────────────────────────────────────────────────────────
650
716
  // Grant Methods
@@ -653,9 +719,11 @@ var ApprovalService = class {
653
719
  * Grant session-scoped approval for a tool.
654
720
  */
655
721
  async grantSessionApproval(toolId, options = {}) {
722
+ const ttlMs = this.checkGrant(toolId, "session" /* SESSION */);
656
723
  return this.store.grantApproval({
657
724
  toolId,
658
725
  scope: "session" /* SESSION */,
726
+ ttlMs,
659
727
  sessionId: this.sessionId,
660
728
  grantedBy: options.grantedBy ?? "policy",
661
729
  reason: options.reason,
@@ -669,9 +737,11 @@ var ApprovalService = class {
669
737
  if (!this.userId) {
670
738
  throw new Error("Cannot grant user approval without userId");
671
739
  }
740
+ const ttlMs = this.checkGrant(toolId, "user" /* USER */);
672
741
  return this.store.grantApproval({
673
742
  toolId,
674
743
  scope: "user" /* USER */,
744
+ ttlMs,
675
745
  userId: this.userId,
676
746
  grantedBy: options.grantedBy ?? "policy",
677
747
  reason: options.reason,
@@ -682,6 +752,7 @@ var ApprovalService = class {
682
752
  * Grant time-limited approval for a tool.
683
753
  */
684
754
  async grantTimeLimitedApproval(toolId, ttlMs, options = {}) {
755
+ this.checkGrant(toolId, "time_limited" /* TIME_LIMITED */, ttlMs);
685
756
  return this.store.grantApproval({
686
757
  toolId,
687
758
  scope: "time_limited" /* TIME_LIMITED */,
@@ -697,9 +768,11 @@ var ApprovalService = class {
697
768
  * Grant context-specific approval for a tool.
698
769
  */
699
770
  async grantContextApproval(toolId, context, options = {}) {
771
+ const ttlMs = this.checkGrant(toolId, "context_specific" /* CONTEXT_SPECIFIC */);
700
772
  return this.store.grantApproval({
701
773
  toolId,
702
774
  scope: "context_specific" /* CONTEXT_SPECIFIC */,
775
+ ttlMs,
703
776
  context,
704
777
  sessionId: this.sessionId,
705
778
  userId: this.userId,
@@ -712,7 +785,8 @@ var ApprovalService = class {
712
785
  // Revoke Methods
713
786
  // ─────────────────────────────────────────────────────────────────────────────
714
787
  /**
715
- * Revoke approval for a tool.
788
+ * Revoke the caller's approvals of a tool: its session, user, time-limited and context
789
+ * approvals. Returns whether anything was revoked. Recorded denials are kept.
716
790
  */
717
791
  async revokeApproval(toolId, options = {}) {
718
792
  return this.store.revokeApproval({
@@ -731,18 +805,18 @@ var ApprovalService = class {
731
805
  }
732
806
  };
733
807
  ApprovalService = __decorateClass([
734
- (0, import_sdk3.Provider)({
808
+ (0, import_sdk2.Provider)({
735
809
  name: "provider:approval:service",
736
810
  description: "Service for managing tool approvals",
737
- scope: import_sdk3.ProviderScope.CONTEXT
811
+ scope: import_sdk2.ProviderScope.CONTEXT
738
812
  })
739
813
  ], ApprovalService);
740
- function createApprovalService(store, sessionId, userId) {
741
- return new ApprovalService(store, sessionId, userId);
814
+ function createApprovalService(store, sessionId, userId, requirementOf) {
815
+ return new ApprovalService(store, sessionId, userId, requirementOf);
742
816
  }
743
817
 
744
818
  // plugins/plugin-approval/src/services/challenge.service.ts
745
- var import_sdk4 = require("@frontmcp/sdk");
819
+ var import_sdk3 = require("@frontmcp/sdk");
746
820
  var import_utils2 = require("@frontmcp/utils");
747
821
  var ChallengeService = class {
748
822
  storage;
@@ -876,10 +950,10 @@ var ChallengeService = class {
876
950
  }
877
951
  };
878
952
  ChallengeService = __decorateClass([
879
- (0, import_sdk4.Provider)({
953
+ (0, import_sdk3.Provider)({
880
954
  name: "provider:approval:challenge-service",
881
955
  description: "PKCE challenge service for webhook approval flows",
882
- scope: import_sdk4.ProviderScope.GLOBAL
956
+ scope: import_sdk3.ProviderScope.GLOBAL
883
957
  })
884
958
  ], ChallengeService);
885
959
  function generatePkcePairFromVerifier(codeVerifier) {
@@ -897,11 +971,12 @@ function createMemoryChallengeService(options = {}) {
897
971
  }
898
972
 
899
973
  // plugins/plugin-approval/src/stores/approval-storage.store.ts
900
- var import_sdk5 = require("@frontmcp/sdk");
974
+ var import_sdk4 = require("@frontmcp/sdk");
901
975
  var import_utils3 = require("@frontmcp/utils");
902
976
  function escapePattern(str) {
903
977
  return str.replace(/[*?[\]\\]/g, "\\$&");
904
978
  }
979
+ var TIME_LIMITED_KEY_SEGMENT = `scope:${"time_limited" /* TIME_LIMITED */}`;
905
980
  var ApprovalStorageStore = class {
906
981
  storage;
907
982
  options;
@@ -951,6 +1026,22 @@ var ApprovalStorageStore = class {
951
1026
  if (context) parts.push(`ctx:${context.type}:${context.identifier}`);
952
1027
  return parts.join(":");
953
1028
  }
1029
+ /**
1030
+ * The key of a time-limited approval granted to a session alone or a user alone. Kept apart from
1031
+ * that session's or user's own key, so a time-limited grant never replaces the session or user
1032
+ * approval, or a denial recorded there. (With both a session and a user, `buildKey` is already distinct.)
1033
+ */
1034
+ timeLimitedKey(toolId, sessionId, userId) {
1035
+ return `${this.buildKey(toolId, sessionId, userId)}:${TIME_LIMITED_KEY_SEGMENT}`;
1036
+ }
1037
+ /** The key a new record is stored under. */
1038
+ recordKey(options) {
1039
+ const { toolId, scope, sessionId, userId, context } = options;
1040
+ if (scope === "time_limited" /* TIME_LIMITED */ && !context && !(sessionId && userId)) {
1041
+ return this.timeLimitedKey(toolId, sessionId, userId);
1042
+ }
1043
+ return this.buildKey(toolId, sessionId, userId, context);
1044
+ }
954
1045
  parseRecord(value) {
955
1046
  if (!value) return void 0;
956
1047
  try {
@@ -968,28 +1059,55 @@ var ApprovalStorageStore = class {
968
1059
  return approval.expiresAt !== void 0 && Date.now() > approval.expiresAt;
969
1060
  }
970
1061
  /**
971
- * The session record, else the user record, except that a denial in either scope wins,
972
- * so a session approval cannot mask a user-level denial.
1062
+ * The keys a caller's approvals of a tool can be stored under: its session, its user, both
1063
+ * (time-limited and context grants made through `ApprovalService`), the time-limited keys of its
1064
+ * session and its user, and, for a call in a server-established context, that context (granted
1065
+ * to the session and the user, or to the session before it had a user, the way a session approval
1066
+ * stays in force once the caller's user is known). Records written by 1.8.2 sit under the same
1067
+ * keys (a time-limited grant without a user under the session key), so they are still read.
973
1068
  */
974
- async getApproval(toolId, sessionId, userId) {
975
- this.ensureInitialized();
976
- const keys = [this.buildKey(toolId, sessionId)];
1069
+ callerKeys(toolId, sessionId, userId, context) {
1070
+ const keys = [this.buildKey(toolId, sessionId), this.timeLimitedKey(toolId, sessionId)];
977
1071
  if (userId) {
978
- keys.push(this.buildKey(toolId, void 0, userId));
1072
+ keys.push(
1073
+ this.buildKey(toolId, void 0, userId),
1074
+ this.timeLimitedKey(toolId, void 0, userId),
1075
+ this.buildKey(toolId, sessionId, userId)
1076
+ );
979
1077
  }
980
- const activeRecords = [];
981
- for (const key of keys) {
1078
+ if (context) {
1079
+ keys.push(this.buildKey(toolId, sessionId, void 0, context));
1080
+ if (userId) keys.push(this.buildKey(toolId, sessionId, userId, context));
1081
+ }
1082
+ return [...new Set(keys)];
1083
+ }
1084
+ /**
1085
+ * Every unexpired record that applies to this caller: session, user, time-limited and, when
1086
+ * `context` is given, context-specific approvals and denials.
1087
+ */
1088
+ async getApprovals(toolId, sessionId, userId, context) {
1089
+ this.ensureInitialized();
1090
+ const records = [];
1091
+ for (const key of this.callerKeys(toolId, sessionId, userId, context)) {
982
1092
  const record = this.parseRecord(await this.storage.get(key));
983
- if (record && !this.isExpired(record)) {
984
- activeRecords.push(record);
1093
+ if (record && record.toolId === toolId && !this.isExpired(record)) {
1094
+ records.push(record);
985
1095
  }
986
1096
  }
987
- return activeRecords.find((record) => record.state === "denied" /* DENIED */) ?? activeRecords[0];
1097
+ return records;
1098
+ }
1099
+ /**
1100
+ * The caller's record for a tool; a denial in any of its scopes wins, so an approval in one
1101
+ * scope cannot mask a denial in another.
1102
+ */
1103
+ async getApproval(toolId, sessionId, userId, context) {
1104
+ const records = await this.getApprovals(toolId, sessionId, userId, context);
1105
+ return records.find((record) => record.state === "denied" /* DENIED */) ?? records[0];
988
1106
  }
989
1107
  async queryApprovals(query) {
990
1108
  this.ensureInitialized();
991
1109
  const results = [];
992
- const pattern = query.toolId ? `${query.toolId}:*` : "*";
1110
+ const pattern = query.toolId ? `${escapePattern(query.toolId)}:*` : "*";
993
1111
  const keys = await this.storage.keys(pattern);
994
1112
  const values = await this.storage.mget(keys);
995
1113
  for (const value of values) {
@@ -1017,8 +1135,12 @@ var ApprovalStorageStore = class {
1017
1135
  }
1018
1136
  async grantApproval(options) {
1019
1137
  this.ensureInitialized();
1138
+ assertValidTtl(options.ttlMs);
1139
+ if (options.scope === "time_limited" /* TIME_LIMITED */ && options.ttlMs === void 0) {
1140
+ throw new ApprovalOperationError("grant", "a time-limited approval needs ttlMs");
1141
+ }
1020
1142
  const now = Date.now();
1021
- const expiresAt = options.ttlMs ? now + options.ttlMs : void 0;
1143
+ const expiresAt = options.ttlMs !== void 0 ? now + options.ttlMs : void 0;
1022
1144
  const grantedBy = normalizeGrantor(options.grantedBy);
1023
1145
  const record = {
1024
1146
  toolId: options.toolId,
@@ -1034,52 +1156,59 @@ var ApprovalStorageStore = class {
1034
1156
  reason: options.reason,
1035
1157
  metadata: options.metadata
1036
1158
  };
1037
- const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
1038
- const ttlSeconds = options.ttlMs ? Math.ceil(options.ttlMs / 1e3) : void 0;
1159
+ const key = this.recordKey(options);
1160
+ const ttlSeconds = options.ttlMs !== void 0 ? Math.ceil(options.ttlMs / 1e3) : void 0;
1039
1161
  await this.storage.set(key, JSON.stringify(record), { ttlSeconds });
1040
1162
  return record;
1041
1163
  }
1164
+ /**
1165
+ * Deletes the approvals of a tool that belong to the given session or user: with a context, only
1166
+ * that context's approval; otherwise every approval of the tool stored for that session or user
1167
+ * (session, user, time-limited and context approvals alike). Recorded denials are kept.
1168
+ */
1042
1169
  async revokeApproval(options) {
1043
1170
  this.ensureInitialized();
1044
- const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
1045
- const exists = await this.storage.exists(key);
1046
- if (exists) {
1047
- await this.storage.delete(key);
1048
- return true;
1171
+ const { toolId, sessionId, userId, context } = options;
1172
+ if (!sessionId && !userId && !context) {
1173
+ return false;
1049
1174
  }
1050
- return false;
1051
- }
1052
- async isApproved(toolId, sessionId, userId, context) {
1053
- this.ensureInitialized();
1054
- if (context) {
1055
- const contextKey = this.buildKey(toolId, sessionId, userId, context);
1056
- const contextValue = await this.storage.get(contextKey);
1057
- const contextApproval = this.parseRecord(contextValue);
1058
- if (contextApproval && contextApproval.state === "approved" /* APPROVED */ && !this.isExpired(contextApproval)) {
1059
- return true;
1175
+ const keys = await this.storage.keys(`${escapePattern(toolId)}:*`);
1176
+ const values = await this.storage.mget(keys);
1177
+ const keysToDelete = [];
1178
+ for (let i = 0; i < keys.length; i++) {
1179
+ const record = this.parseRecord(values[i]);
1180
+ if (!record || record.toolId !== toolId || record.state === "denied" /* DENIED */) continue;
1181
+ if (context) {
1182
+ const sameSession = sessionId !== void 0 && record.sessionId === sessionId;
1183
+ if (record.context?.type === context.type && record.context.identifier === context.identifier && (!sessionId || sameSession) && // A context approval granted to the session before it had a user applies to that user too.
1184
+ (!userId || record.userId === userId || sameSession && record.userId === void 0)) {
1185
+ keysToDelete.push(keys[i]);
1186
+ }
1187
+ continue;
1060
1188
  }
1061
- }
1062
- const sessionKey = this.buildKey(toolId, sessionId);
1063
- const sessionValue = await this.storage.get(sessionKey);
1064
- const sessionApproval = this.parseRecord(sessionValue);
1065
- if (sessionApproval && sessionApproval.state === "approved" /* APPROVED */ && !this.isExpired(sessionApproval)) {
1066
- return true;
1067
- }
1068
- if (userId) {
1069
- const userKey = this.buildKey(toolId, void 0, userId);
1070
- const userValue = await this.storage.get(userKey);
1071
- const userApproval = this.parseRecord(userValue);
1072
- if (userApproval && userApproval.state === "approved" /* APPROVED */ && !this.isExpired(userApproval)) {
1073
- return true;
1189
+ if (sessionId && record.sessionId === sessionId || userId && record.userId === userId) {
1190
+ keysToDelete.push(keys[i]);
1074
1191
  }
1075
1192
  }
1076
- return false;
1193
+ if (keysToDelete.length === 0) {
1194
+ return false;
1195
+ }
1196
+ await this.storage.mdelete(keysToDelete);
1197
+ return true;
1198
+ }
1199
+ async isApproved(toolId, sessionId, userId, context) {
1200
+ const approval = await this.getApproval(toolId, sessionId, userId, context);
1201
+ return approval?.state === "approved" /* APPROVED */;
1077
1202
  }
1078
1203
  async clearSessionApprovals(sessionId) {
1079
1204
  this.ensureInitialized();
1080
1205
  const escapedSessionId = escapePattern(sessionId);
1081
- const pattern = `*:session:${escapedSessionId}*`;
1082
- const keys = await this.storage.keys(pattern);
1206
+ const keys = [
1207
+ .../* @__PURE__ */ new Set([
1208
+ ...await this.storage.keys(`*:session:${escapedSessionId}`),
1209
+ ...await this.storage.keys(`*:session:${escapedSessionId}:*`)
1210
+ ])
1211
+ ];
1083
1212
  if (keys.length === 0) {
1084
1213
  return 0;
1085
1214
  }
@@ -1146,10 +1275,10 @@ var ApprovalStorageStore = class {
1146
1275
  }
1147
1276
  };
1148
1277
  ApprovalStorageStore = __decorateClass([
1149
- (0, import_sdk5.Provider)({
1278
+ (0, import_sdk4.Provider)({
1150
1279
  name: "provider:approval:store:storage",
1151
1280
  description: "Storage-backed approval store (supports Memory, Redis, Vercel KV, Upstash)",
1152
- scope: import_sdk5.ProviderScope.GLOBAL
1281
+ scope: import_sdk4.ProviderScope.GLOBAL
1153
1282
  })
1154
1283
  ], ApprovalStorageStore);
1155
1284
  function createApprovalMemoryStore(options = {}) {
@@ -1161,7 +1290,7 @@ function createApprovalMemoryStore(options = {}) {
1161
1290
  }
1162
1291
 
1163
1292
  // plugins/plugin-approval/src/approval.plugin.ts
1164
- var ApprovalPlugin = class extends import_sdk6.DynamicPlugin {
1293
+ var ApprovalPlugin = class extends import_sdk5.DynamicPlugin {
1165
1294
  options;
1166
1295
  constructor(options = {}) {
1167
1296
  super();
@@ -1222,17 +1351,23 @@ __publicField(ApprovalPlugin, "dynamicProviders", (options) => {
1222
1351
  providers.push({
1223
1352
  name: "approval:service",
1224
1353
  provide: ApprovalServiceToken,
1225
- scope: import_sdk6.ProviderScope.CONTEXT,
1226
- inject: () => [ApprovalStoreToken, import_sdk6.FRONTMCP_CONTEXT],
1227
- useFactory: (store, ctx) => {
1354
+ scope: import_sdk5.ProviderScope.CONTEXT,
1355
+ inject: () => [ApprovalStoreToken, import_sdk5.FRONTMCP_CONTEXT, import_sdk5.ScopeEntry],
1356
+ useFactory: (store, ctx, scope) => {
1228
1357
  const { sessionId, userId } = resolveApprovalIdentity(ctx);
1229
- return createApprovalService(store, sessionId, userId);
1358
+ const requirementOf = (toolId) => {
1359
+ const tool = scope.tools.getTools(true).find((entry) => entry.fullName === toolId);
1360
+ if (!tool) return void 0;
1361
+ const approval = tool.metadata.approval;
1362
+ return resolveApprovalRequirement(approval);
1363
+ };
1364
+ return createApprovalService(store, sessionId, userId, requirementOf);
1230
1365
  }
1231
1366
  });
1232
1367
  return providers;
1233
1368
  });
1234
1369
  ApprovalPlugin = __decorateClass([
1235
- (0, import_sdk6.Plugin)({
1370
+ (0, import_sdk5.Plugin)({
1236
1371
  name: "approval",
1237
1372
  description: "Tool authorization workflow with PKCE webhook security",
1238
1373
  plugins: [ApprovalCheckPlugin],