@frontmcp/plugin-approval 1.8.2 → 1.8.4

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,6 +78,205 @@ 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/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
+ }
279
+
81
280
  // plugins/plugin-approval/src/approval.identity.ts
82
281
  var import_utils = require("@frontmcp/utils");
83
282
  function nonEmptyString(value) {
@@ -107,23 +306,6 @@ var ChallengeServiceToken = /* @__PURE__ */ Symbol.for(
107
306
  // plugins/plugin-approval/src/hooks/approval-check.hook.ts
108
307
  var import_sdk = require("@frontmcp/sdk");
109
308
 
110
- // plugins/plugin-approval/src/approval/types.ts
111
- var ApprovalScope = /* @__PURE__ */ ((ApprovalScope2) => {
112
- ApprovalScope2["SESSION"] = "session";
113
- ApprovalScope2["USER"] = "user";
114
- ApprovalScope2["TIME_LIMITED"] = "time_limited";
115
- ApprovalScope2["TOOL_SPECIFIC"] = "tool_specific";
116
- ApprovalScope2["CONTEXT_SPECIFIC"] = "context_specific";
117
- return ApprovalScope2;
118
- })(ApprovalScope || {});
119
- var ApprovalState = /* @__PURE__ */ ((ApprovalState2) => {
120
- ApprovalState2["PENDING"] = "pending";
121
- ApprovalState2["APPROVED"] = "approved";
122
- ApprovalState2["DENIED"] = "denied";
123
- ApprovalState2["EXPIRED"] = "expired";
124
- return ApprovalState2;
125
- })(ApprovalState || {});
126
-
127
309
  // plugins/plugin-approval/src/approval/schemas.ts
128
310
  var import_lazy_zod = require("@frontmcp/lazy-zod");
129
311
  var approvalScopeSchema = import_lazy_zod.z.nativeEnum(ApprovalScope);
@@ -340,136 +522,6 @@ function isApiGrantor(grantor) {
340
522
  return grantor.source === "api" || grantor.source === "oauth";
341
523
  }
342
524
 
343
- // plugins/plugin-approval/src/approval/errors.ts
344
- var ApprovalError = class extends Error {
345
- constructor(message) {
346
- super(message);
347
- this.name = "ApprovalError";
348
- }
349
- };
350
- var ApprovalRequiredError = class extends ApprovalError {
351
- constructor(details) {
352
- super(details.message);
353
- this.details = details;
354
- this.name = "ApprovalRequiredError";
355
- }
356
- details;
357
- /**
358
- * Convert to a JSON-RPC compatible error structure.
359
- */
360
- toJsonRpcError() {
361
- return {
362
- code: -32600,
363
- // Invalid Request
364
- message: this.details.message,
365
- data: {
366
- type: "approval_required",
367
- toolId: this.details.toolId,
368
- state: this.details.state,
369
- options: this.details.approvalOptions
370
- }
371
- };
372
- }
373
- };
374
- var ApprovalOperationError = class extends ApprovalError {
375
- constructor(operation, reason) {
376
- super(`Approval ${operation} failed: ${reason}`);
377
- this.operation = operation;
378
- this.reason = reason;
379
- this.name = "ApprovalOperationError";
380
- }
381
- operation;
382
- reason;
383
- /**
384
- * Convert to a JSON-RPC compatible error structure.
385
- */
386
- toJsonRpcError() {
387
- return {
388
- code: -32603,
389
- // Internal Error
390
- message: "Approval operation failed",
391
- data: {
392
- type: "approval_operation_error",
393
- operation: this.operation
394
- }
395
- };
396
- }
397
- };
398
- var ApprovalScopeNotAllowedError = class extends ApprovalError {
399
- constructor(requestedScope, allowedScopes) {
400
- super(
401
- `Approval scope '${requestedScope}' is not allowed for this tool. Allowed scopes: ${allowedScopes.join(", ")}`
402
- );
403
- this.requestedScope = requestedScope;
404
- this.allowedScopes = allowedScopes;
405
- this.name = "ApprovalScopeNotAllowedError";
406
- }
407
- requestedScope;
408
- allowedScopes;
409
- /**
410
- * Convert to a JSON-RPC compatible error structure.
411
- */
412
- toJsonRpcError() {
413
- return {
414
- code: -32602,
415
- // Invalid Params
416
- message: this.message,
417
- data: {
418
- type: "approval_scope_not_allowed",
419
- requestedScope: this.requestedScope,
420
- allowedScopes: this.allowedScopes
421
- }
422
- };
423
- }
424
- };
425
- var ApprovalExpiredError = class extends ApprovalError {
426
- constructor(toolId, expiredAt) {
427
- super(`Approval for tool '${toolId}' expired at ${new Date(expiredAt).toISOString()}`);
428
- this.toolId = toolId;
429
- this.expiredAt = expiredAt;
430
- this.name = "ApprovalExpiredError";
431
- }
432
- toolId;
433
- expiredAt;
434
- /**
435
- * Convert to a JSON-RPC compatible error structure.
436
- */
437
- toJsonRpcError() {
438
- return {
439
- code: -32600,
440
- // Invalid Request
441
- message: this.message,
442
- data: {
443
- type: "approval_expired",
444
- toolId: this.toolId,
445
- expiredAt: this.expiredAt
446
- }
447
- };
448
- }
449
- };
450
- var ChallengeValidationError = class extends ApprovalError {
451
- constructor(reason = "invalid", message) {
452
- super(message ?? `PKCE challenge validation failed: ${reason}`);
453
- this.reason = reason;
454
- this.name = "ChallengeValidationError";
455
- }
456
- reason;
457
- /**
458
- * Convert to a JSON-RPC compatible error structure.
459
- */
460
- toJsonRpcError() {
461
- return {
462
- code: -32600,
463
- // Invalid Request
464
- message: this.message,
465
- data: {
466
- type: "challenge_validation_error",
467
- reason: this.reason
468
- }
469
- };
470
- }
471
- };
472
-
473
525
  // plugins/plugin-approval/src/hooks/approval-check.hook.ts
474
526
  var passedApprovalStores = /* @__PURE__ */ new WeakMap();
475
527
  var ApprovalCheckPlugin = class extends import_sdk.DynamicPlugin {
@@ -477,7 +529,7 @@ var ApprovalCheckPlugin = class extends import_sdk.DynamicPlugin {
477
529
  const { tool, toolContext } = flowCtx.state;
478
530
  if (!tool || !toolContext) return;
479
531
  const metadata = tool.metadata;
480
- const approvalConfig = this.resolveApprovalConfig(
532
+ const approvalConfig = resolveApprovalRequirement(
481
533
  metadata["approval"]
482
534
  );
483
535
  if (!approvalConfig.required) {
@@ -495,44 +547,43 @@ var ApprovalCheckPlugin = class extends import_sdk.DynamicPlugin {
495
547
  }
496
548
  async enforceApproval(flowCtx, tool, toolContext, approvalConfig, approvalStore) {
497
549
  const { sessionId, userId } = resolveApprovalIdentity(toolContext.tryGetContext?.());
498
- const approval = await approvalStore.getApproval(tool.fullName, sessionId, userId);
499
- 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 */)) {
500
553
  throw new ApprovalRequiredError({
501
554
  toolId: tool.fullName,
502
555
  state: "denied",
503
556
  message: `Tool "${tool.fullName}" execution denied.`
504
557
  });
505
558
  }
506
- if (this.isPreApprovedContext(approvalConfig, this.getCurrentContext(flowCtx))) {
559
+ if (this.isPreApprovedContext(approvalConfig, currentContext)) {
507
560
  return;
508
561
  }
562
+ const approved = records.filter((record) => record.state === "approved" /* APPROVED */);
509
563
  if (approvalConfig.alwaysPrompt) {
510
- await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
564
+ await this.handleApprovalRequired(flowCtx, approvalConfig, approved[0]);
511
565
  return;
512
566
  }
513
- if (approval?.state === "approved" /* APPROVED */) {
514
- if (!this.isExpired(approval)) {
515
- return;
516
- }
567
+ const now = Date.now();
568
+ if (approved.some((record) => isApprovalUsable(record, approvalConfig, now))) {
569
+ return;
517
570
  }
518
- 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
+ );
519
576
  }
520
- resolveApprovalConfig(config) {
521
- if (config === true) {
522
- return { required: true, defaultScope: "session" /* SESSION */ };
523
- }
524
- if (config === false || config === void 0) {
525
- 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);
526
584
  }
527
- return {
528
- ...config,
529
- required: config.required ?? true,
530
- defaultScope: config.defaultScope ?? "session" /* SESSION */
531
- };
532
- }
533
- isExpired(approval) {
534
- if (!approval.expiresAt) return false;
535
- return Date.now() > approval.expiresAt;
585
+ const record = await store.getApproval(toolId, sessionId, userId, context);
586
+ return record ? [record] : [];
536
587
  }
537
588
  /**
538
589
  * The context this call is running in, as established by the SESSION (GHSA-r848-p7wf-96rc).
@@ -562,7 +613,7 @@ var ApprovalCheckPlugin = class extends import_sdk.DynamicPlugin {
562
613
  async handleApprovalRequired(flowCtx, config, existingApproval) {
563
614
  const { tool } = flowCtx.state;
564
615
  const message = config.approvalMessage ?? `Tool "${tool?.fullName}" requires approval to execute. Allow?`;
565
- const isExpiredApproval = existingApproval ? this.isExpired(existingApproval) : false;
616
+ const isExpiredApproval = existingApproval ? isApprovalExpired(existingApproval, config, Date.now()) : false;
566
617
  throw new ApprovalRequiredError({
567
618
  toolId: tool?.fullName ?? "unknown",
568
619
  state: isExpiredApproval ? "expired" : "pending",
@@ -578,26 +629,34 @@ var ApprovalCheckPlugin = class extends import_sdk.DynamicPlugin {
578
629
  }
579
630
  };
580
631
  __decorateClass([
581
- import_sdk.ToolHook.Will("execute", { priority: 100 })
632
+ import_sdk.ToolHook.Will("execute", { priority: 100, appliesTo: "uncovered-apps" })
582
633
  ], ApprovalCheckPlugin.prototype, "checkApproval", 1);
583
634
  ApprovalCheckPlugin = __decorateClass([
584
635
  (0, import_sdk.Plugin)({
585
636
  name: "approval:check",
586
- 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"]
587
640
  })
588
641
  ], ApprovalCheckPlugin);
589
642
 
590
643
  // plugins/plugin-approval/src/services/approval.service.ts
591
644
  var import_sdk2 = require("@frontmcp/sdk");
592
645
  var ApprovalService = class {
593
- constructor(store, sessionId, userId) {
646
+ constructor(store, sessionId, userId, requirementOf = () => void 0) {
594
647
  this.store = store;
595
648
  this.sessionId = sessionId;
596
649
  this.userId = userId;
650
+ this.requirementOf = requirementOf;
597
651
  }
598
652
  store;
599
653
  sessionId;
600
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
+ }
601
660
  // ─────────────────────────────────────────────────────────────────────────────
602
661
  // Query Methods
603
662
  // ─────────────────────────────────────────────────────────────────────────────
@@ -605,7 +664,14 @@ var ApprovalService = class {
605
664
  * Check if a tool is approved for current session/user.
606
665
  */
607
666
  async isApproved(toolId, context) {
608
- 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));
609
675
  }
610
676
  /**
611
677
  * Get approval record for a tool.
@@ -636,14 +702,15 @@ var ApprovalService = class {
636
702
  });
637
703
  }
638
704
  /**
639
- * 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.
640
708
  */
641
709
  async queryApprovals(query) {
642
- return this.store.queryApprovals({
643
- ...query,
644
- sessionId: query.sessionId ?? this.sessionId,
645
- userId: query.userId ?? this.userId
646
- });
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
+ );
647
714
  }
648
715
  // ─────────────────────────────────────────────────────────────────────────────
649
716
  // Grant Methods
@@ -652,9 +719,11 @@ var ApprovalService = class {
652
719
  * Grant session-scoped approval for a tool.
653
720
  */
654
721
  async grantSessionApproval(toolId, options = {}) {
722
+ const ttlMs = this.checkGrant(toolId, "session" /* SESSION */);
655
723
  return this.store.grantApproval({
656
724
  toolId,
657
725
  scope: "session" /* SESSION */,
726
+ ttlMs,
658
727
  sessionId: this.sessionId,
659
728
  grantedBy: options.grantedBy ?? "policy",
660
729
  reason: options.reason,
@@ -668,9 +737,11 @@ var ApprovalService = class {
668
737
  if (!this.userId) {
669
738
  throw new Error("Cannot grant user approval without userId");
670
739
  }
740
+ const ttlMs = this.checkGrant(toolId, "user" /* USER */);
671
741
  return this.store.grantApproval({
672
742
  toolId,
673
743
  scope: "user" /* USER */,
744
+ ttlMs,
674
745
  userId: this.userId,
675
746
  grantedBy: options.grantedBy ?? "policy",
676
747
  reason: options.reason,
@@ -681,6 +752,7 @@ var ApprovalService = class {
681
752
  * Grant time-limited approval for a tool.
682
753
  */
683
754
  async grantTimeLimitedApproval(toolId, ttlMs, options = {}) {
755
+ this.checkGrant(toolId, "time_limited" /* TIME_LIMITED */, ttlMs);
684
756
  return this.store.grantApproval({
685
757
  toolId,
686
758
  scope: "time_limited" /* TIME_LIMITED */,
@@ -696,9 +768,11 @@ var ApprovalService = class {
696
768
  * Grant context-specific approval for a tool.
697
769
  */
698
770
  async grantContextApproval(toolId, context, options = {}) {
771
+ const ttlMs = this.checkGrant(toolId, "context_specific" /* CONTEXT_SPECIFIC */);
699
772
  return this.store.grantApproval({
700
773
  toolId,
701
774
  scope: "context_specific" /* CONTEXT_SPECIFIC */,
775
+ ttlMs,
702
776
  context,
703
777
  sessionId: this.sessionId,
704
778
  userId: this.userId,
@@ -711,7 +785,8 @@ var ApprovalService = class {
711
785
  // Revoke Methods
712
786
  // ─────────────────────────────────────────────────────────────────────────────
713
787
  /**
714
- * 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.
715
790
  */
716
791
  async revokeApproval(toolId, options = {}) {
717
792
  return this.store.revokeApproval({
@@ -736,8 +811,8 @@ ApprovalService = __decorateClass([
736
811
  scope: import_sdk2.ProviderScope.CONTEXT
737
812
  })
738
813
  ], ApprovalService);
739
- function createApprovalService(store, sessionId, userId) {
740
- return new ApprovalService(store, sessionId, userId);
814
+ function createApprovalService(store, sessionId, userId, requirementOf) {
815
+ return new ApprovalService(store, sessionId, userId, requirementOf);
741
816
  }
742
817
 
743
818
  // plugins/plugin-approval/src/services/challenge.service.ts
@@ -901,6 +976,7 @@ var import_utils3 = require("@frontmcp/utils");
901
976
  function escapePattern(str) {
902
977
  return str.replace(/[*?[\]\\]/g, "\\$&");
903
978
  }
979
+ var TIME_LIMITED_KEY_SEGMENT = `scope:${"time_limited" /* TIME_LIMITED */}`;
904
980
  var ApprovalStorageStore = class {
905
981
  storage;
906
982
  options;
@@ -950,6 +1026,22 @@ var ApprovalStorageStore = class {
950
1026
  if (context) parts.push(`ctx:${context.type}:${context.identifier}`);
951
1027
  return parts.join(":");
952
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
+ }
953
1045
  parseRecord(value) {
954
1046
  if (!value) return void 0;
955
1047
  try {
@@ -967,28 +1059,55 @@ var ApprovalStorageStore = class {
967
1059
  return approval.expiresAt !== void 0 && Date.now() > approval.expiresAt;
968
1060
  }
969
1061
  /**
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.
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.
972
1068
  */
973
- async getApproval(toolId, sessionId, userId) {
974
- this.ensureInitialized();
975
- const keys = [this.buildKey(toolId, sessionId)];
1069
+ callerKeys(toolId, sessionId, userId, context) {
1070
+ const keys = [this.buildKey(toolId, sessionId), this.timeLimitedKey(toolId, sessionId)];
976
1071
  if (userId) {
977
- 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
+ );
978
1077
  }
979
- const activeRecords = [];
980
- 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)) {
981
1092
  const record = this.parseRecord(await this.storage.get(key));
982
- if (record && !this.isExpired(record)) {
983
- activeRecords.push(record);
1093
+ if (record && record.toolId === toolId && !this.isExpired(record)) {
1094
+ records.push(record);
984
1095
  }
985
1096
  }
986
- 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];
987
1106
  }
988
1107
  async queryApprovals(query) {
989
1108
  this.ensureInitialized();
990
1109
  const results = [];
991
- const pattern = query.toolId ? `${query.toolId}:*` : "*";
1110
+ const pattern = query.toolId ? `${escapePattern(query.toolId)}:*` : "*";
992
1111
  const keys = await this.storage.keys(pattern);
993
1112
  const values = await this.storage.mget(keys);
994
1113
  for (const value of values) {
@@ -1016,8 +1135,12 @@ var ApprovalStorageStore = class {
1016
1135
  }
1017
1136
  async grantApproval(options) {
1018
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
+ }
1019
1142
  const now = Date.now();
1020
- const expiresAt = options.ttlMs ? now + options.ttlMs : void 0;
1143
+ const expiresAt = options.ttlMs !== void 0 ? now + options.ttlMs : void 0;
1021
1144
  const grantedBy = normalizeGrantor(options.grantedBy);
1022
1145
  const record = {
1023
1146
  toolId: options.toolId,
@@ -1033,52 +1156,59 @@ var ApprovalStorageStore = class {
1033
1156
  reason: options.reason,
1034
1157
  metadata: options.metadata
1035
1158
  };
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;
1159
+ const key = this.recordKey(options);
1160
+ const ttlSeconds = options.ttlMs !== void 0 ? Math.ceil(options.ttlMs / 1e3) : void 0;
1038
1161
  await this.storage.set(key, JSON.stringify(record), { ttlSeconds });
1039
1162
  return record;
1040
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
+ */
1041
1169
  async revokeApproval(options) {
1042
1170
  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;
1171
+ const { toolId, sessionId, userId, context } = options;
1172
+ if (!sessionId && !userId && !context) {
1173
+ return false;
1048
1174
  }
1049
- return false;
1050
- }
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;
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;
1059
1188
  }
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;
1189
+ if (sessionId && record.sessionId === sessionId || userId && record.userId === userId) {
1190
+ keysToDelete.push(keys[i]);
1073
1191
  }
1074
1192
  }
1075
- 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 */;
1076
1202
  }
1077
1203
  async clearSessionApprovals(sessionId) {
1078
1204
  this.ensureInitialized();
1079
1205
  const escapedSessionId = escapePattern(sessionId);
1080
- const pattern = `*:session:${escapedSessionId}*`;
1081
- 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
+ ];
1082
1212
  if (keys.length === 0) {
1083
1213
  return 0;
1084
1214
  }
@@ -1222,10 +1352,16 @@ __publicField(ApprovalPlugin, "dynamicProviders", (options) => {
1222
1352
  name: "approval:service",
1223
1353
  provide: ApprovalServiceToken,
1224
1354
  scope: import_sdk5.ProviderScope.CONTEXT,
1225
- inject: () => [ApprovalStoreToken, import_sdk5.FRONTMCP_CONTEXT],
1226
- useFactory: (store, ctx) => {
1355
+ inject: () => [ApprovalStoreToken, import_sdk5.FRONTMCP_CONTEXT, import_sdk5.ScopeEntry],
1356
+ useFactory: (store, ctx, scope) => {
1227
1357
  const { sessionId, userId } = resolveApprovalIdentity(ctx);
1228
- 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);
1229
1365
  }
1230
1366
  });
1231
1367
  return providers;