@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/esm/index.mjs CHANGED
@@ -22,9 +22,209 @@ import {
22
22
  DynamicPlugin as DynamicPlugin2,
23
23
  FRONTMCP_CONTEXT,
24
24
  Plugin as Plugin2,
25
- ProviderScope as ProviderScope4
25
+ ProviderScope as ProviderScope4,
26
+ ScopeEntry
26
27
  } from "@frontmcp/sdk";
27
28
 
29
+ // plugins/plugin-approval/src/approval/errors.ts
30
+ var ApprovalError = class extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = "ApprovalError";
34
+ }
35
+ };
36
+ var ApprovalRequiredError = class extends ApprovalError {
37
+ constructor(details) {
38
+ super(details.message);
39
+ this.details = details;
40
+ this.name = "ApprovalRequiredError";
41
+ }
42
+ details;
43
+ /**
44
+ * Convert to a JSON-RPC compatible error structure.
45
+ */
46
+ toJsonRpcError() {
47
+ return {
48
+ code: -32600,
49
+ // Invalid Request
50
+ message: this.details.message,
51
+ data: {
52
+ type: "approval_required",
53
+ toolId: this.details.toolId,
54
+ state: this.details.state,
55
+ options: this.details.approvalOptions
56
+ }
57
+ };
58
+ }
59
+ };
60
+ var ApprovalOperationError = class extends ApprovalError {
61
+ constructor(operation, reason) {
62
+ super(`Approval ${operation} failed: ${reason}`);
63
+ this.operation = operation;
64
+ this.reason = reason;
65
+ this.name = "ApprovalOperationError";
66
+ }
67
+ operation;
68
+ reason;
69
+ /**
70
+ * Convert to a JSON-RPC compatible error structure.
71
+ */
72
+ toJsonRpcError() {
73
+ return {
74
+ code: -32603,
75
+ // Internal Error
76
+ message: "Approval operation failed",
77
+ data: {
78
+ type: "approval_operation_error",
79
+ operation: this.operation
80
+ }
81
+ };
82
+ }
83
+ };
84
+ var ApprovalScopeNotAllowedError = class extends ApprovalError {
85
+ constructor(requestedScope, allowedScopes) {
86
+ super(
87
+ `Approval scope '${requestedScope}' is not allowed for this tool. Allowed scopes: ${allowedScopes.join(", ")}`
88
+ );
89
+ this.requestedScope = requestedScope;
90
+ this.allowedScopes = allowedScopes;
91
+ this.name = "ApprovalScopeNotAllowedError";
92
+ }
93
+ requestedScope;
94
+ allowedScopes;
95
+ /**
96
+ * Convert to a JSON-RPC compatible error structure.
97
+ */
98
+ toJsonRpcError() {
99
+ return {
100
+ code: -32602,
101
+ // Invalid Params
102
+ message: this.message,
103
+ data: {
104
+ type: "approval_scope_not_allowed",
105
+ requestedScope: this.requestedScope,
106
+ allowedScopes: this.allowedScopes
107
+ }
108
+ };
109
+ }
110
+ };
111
+ var ApprovalExpiredError = class extends ApprovalError {
112
+ constructor(toolId, expiredAt) {
113
+ super(`Approval for tool '${toolId}' expired at ${new Date(expiredAt).toISOString()}`);
114
+ this.toolId = toolId;
115
+ this.expiredAt = expiredAt;
116
+ this.name = "ApprovalExpiredError";
117
+ }
118
+ toolId;
119
+ expiredAt;
120
+ /**
121
+ * Convert to a JSON-RPC compatible error structure.
122
+ */
123
+ toJsonRpcError() {
124
+ return {
125
+ code: -32600,
126
+ // Invalid Request
127
+ message: this.message,
128
+ data: {
129
+ type: "approval_expired",
130
+ toolId: this.toolId,
131
+ expiredAt: this.expiredAt
132
+ }
133
+ };
134
+ }
135
+ };
136
+ var ChallengeValidationError = class extends ApprovalError {
137
+ constructor(reason = "invalid", message) {
138
+ super(message ?? `PKCE challenge validation failed: ${reason}`);
139
+ this.reason = reason;
140
+ this.name = "ChallengeValidationError";
141
+ }
142
+ reason;
143
+ /**
144
+ * Convert to a JSON-RPC compatible error structure.
145
+ */
146
+ toJsonRpcError() {
147
+ return {
148
+ code: -32600,
149
+ // Invalid Request
150
+ message: this.message,
151
+ data: {
152
+ type: "challenge_validation_error",
153
+ reason: this.reason
154
+ }
155
+ };
156
+ }
157
+ };
158
+
159
+ // plugins/plugin-approval/src/approval/types.ts
160
+ var ApprovalScope = /* @__PURE__ */ ((ApprovalScope2) => {
161
+ ApprovalScope2["SESSION"] = "session";
162
+ ApprovalScope2["USER"] = "user";
163
+ ApprovalScope2["TIME_LIMITED"] = "time_limited";
164
+ ApprovalScope2["TOOL_SPECIFIC"] = "tool_specific";
165
+ ApprovalScope2["CONTEXT_SPECIFIC"] = "context_specific";
166
+ return ApprovalScope2;
167
+ })(ApprovalScope || {});
168
+ var ApprovalState = /* @__PURE__ */ ((ApprovalState2) => {
169
+ ApprovalState2["PENDING"] = "pending";
170
+ ApprovalState2["APPROVED"] = "approved";
171
+ ApprovalState2["DENIED"] = "denied";
172
+ ApprovalState2["EXPIRED"] = "expired";
173
+ return ApprovalState2;
174
+ })(ApprovalState || {});
175
+
176
+ // plugins/plugin-approval/src/approval/policy.ts
177
+ function resolveApprovalRequirement(config) {
178
+ if (config === true) {
179
+ return { required: true, defaultScope: "session" /* SESSION */ };
180
+ }
181
+ if (config === false || config === void 0) {
182
+ return { required: false };
183
+ }
184
+ return {
185
+ ...config,
186
+ required: config.required ?? true,
187
+ defaultScope: config.defaultScope ?? "session" /* SESSION */
188
+ };
189
+ }
190
+ function effectiveExpiresAt(record, requirement) {
191
+ const capped = requirement.maxTtlMs !== void 0 ? record.grantedAt + requirement.maxTtlMs : void 0;
192
+ if (record.expiresAt === void 0) return capped;
193
+ return capped === void 0 ? record.expiresAt : Math.min(record.expiresAt, capped);
194
+ }
195
+ function isApprovalExpired(record, requirement, now) {
196
+ const expiresAt = effectiveExpiresAt(record, requirement);
197
+ return expiresAt !== void 0 && now > expiresAt;
198
+ }
199
+ function isApprovalUsable(record, requirement, now) {
200
+ if (record.state !== "approved" /* APPROVED */) return false;
201
+ if (requirement.allowedScopes?.length && !requirement.allowedScopes.includes(record.scope)) return false;
202
+ return !isApprovalExpired(record, requirement, now);
203
+ }
204
+ function assertValidTtl(ttlMs) {
205
+ if (ttlMs === void 0) return;
206
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
207
+ throw new ApprovalOperationError("grant", `ttlMs must be a positive number of milliseconds, got ${ttlMs}`);
208
+ }
209
+ }
210
+ function checkGrantAgainstPolicy(toolId, scope, ttlMs, requirement) {
211
+ assertValidTtl(ttlMs);
212
+ if (!requirement) return ttlMs;
213
+ if (requirement.allowedScopes?.length && !requirement.allowedScopes.includes(scope)) {
214
+ throw new ApprovalScopeNotAllowedError(scope, requirement.allowedScopes);
215
+ }
216
+ const { maxTtlMs } = requirement;
217
+ if (maxTtlMs === void 0) return ttlMs;
218
+ if (ttlMs === void 0) return maxTtlMs;
219
+ if (ttlMs > maxTtlMs) {
220
+ throw new ApprovalOperationError(
221
+ "grant",
222
+ `ttlMs ${ttlMs} exceeds the maximum of ${maxTtlMs} ms that tool "${toolId}" allows`
223
+ );
224
+ }
225
+ return ttlMs;
226
+ }
227
+
28
228
  // plugins/plugin-approval/src/approval.identity.ts
29
229
  import { randomUUID } from "@frontmcp/utils";
30
230
  function nonEmptyString(value) {
@@ -54,23 +254,6 @@ var ChallengeServiceToken = /* @__PURE__ */ Symbol.for(
54
254
  // plugins/plugin-approval/src/hooks/approval-check.hook.ts
55
255
  import { DynamicPlugin, Plugin, ToolHook } from "@frontmcp/sdk";
56
256
 
57
- // plugins/plugin-approval/src/approval/types.ts
58
- var ApprovalScope = /* @__PURE__ */ ((ApprovalScope2) => {
59
- ApprovalScope2["SESSION"] = "session";
60
- ApprovalScope2["USER"] = "user";
61
- ApprovalScope2["TIME_LIMITED"] = "time_limited";
62
- ApprovalScope2["TOOL_SPECIFIC"] = "tool_specific";
63
- ApprovalScope2["CONTEXT_SPECIFIC"] = "context_specific";
64
- return ApprovalScope2;
65
- })(ApprovalScope || {});
66
- var ApprovalState = /* @__PURE__ */ ((ApprovalState2) => {
67
- ApprovalState2["PENDING"] = "pending";
68
- ApprovalState2["APPROVED"] = "approved";
69
- ApprovalState2["DENIED"] = "denied";
70
- ApprovalState2["EXPIRED"] = "expired";
71
- return ApprovalState2;
72
- })(ApprovalState || {});
73
-
74
257
  // plugins/plugin-approval/src/approval/schemas.ts
75
258
  import { z } from "@frontmcp/lazy-zod";
76
259
  var approvalScopeSchema = z.nativeEnum(ApprovalScope);
@@ -287,136 +470,6 @@ function isApiGrantor(grantor) {
287
470
  return grantor.source === "api" || grantor.source === "oauth";
288
471
  }
289
472
 
290
- // plugins/plugin-approval/src/approval/errors.ts
291
- var ApprovalError = class extends Error {
292
- constructor(message) {
293
- super(message);
294
- this.name = "ApprovalError";
295
- }
296
- };
297
- var ApprovalRequiredError = class extends ApprovalError {
298
- constructor(details) {
299
- super(details.message);
300
- this.details = details;
301
- this.name = "ApprovalRequiredError";
302
- }
303
- details;
304
- /**
305
- * Convert to a JSON-RPC compatible error structure.
306
- */
307
- toJsonRpcError() {
308
- return {
309
- code: -32600,
310
- // Invalid Request
311
- message: this.details.message,
312
- data: {
313
- type: "approval_required",
314
- toolId: this.details.toolId,
315
- state: this.details.state,
316
- options: this.details.approvalOptions
317
- }
318
- };
319
- }
320
- };
321
- var ApprovalOperationError = class extends ApprovalError {
322
- constructor(operation, reason) {
323
- super(`Approval ${operation} failed: ${reason}`);
324
- this.operation = operation;
325
- this.reason = reason;
326
- this.name = "ApprovalOperationError";
327
- }
328
- operation;
329
- reason;
330
- /**
331
- * Convert to a JSON-RPC compatible error structure.
332
- */
333
- toJsonRpcError() {
334
- return {
335
- code: -32603,
336
- // Internal Error
337
- message: "Approval operation failed",
338
- data: {
339
- type: "approval_operation_error",
340
- operation: this.operation
341
- }
342
- };
343
- }
344
- };
345
- var ApprovalScopeNotAllowedError = class extends ApprovalError {
346
- constructor(requestedScope, allowedScopes) {
347
- super(
348
- `Approval scope '${requestedScope}' is not allowed for this tool. Allowed scopes: ${allowedScopes.join(", ")}`
349
- );
350
- this.requestedScope = requestedScope;
351
- this.allowedScopes = allowedScopes;
352
- this.name = "ApprovalScopeNotAllowedError";
353
- }
354
- requestedScope;
355
- allowedScopes;
356
- /**
357
- * Convert to a JSON-RPC compatible error structure.
358
- */
359
- toJsonRpcError() {
360
- return {
361
- code: -32602,
362
- // Invalid Params
363
- message: this.message,
364
- data: {
365
- type: "approval_scope_not_allowed",
366
- requestedScope: this.requestedScope,
367
- allowedScopes: this.allowedScopes
368
- }
369
- };
370
- }
371
- };
372
- var ApprovalExpiredError = class extends ApprovalError {
373
- constructor(toolId, expiredAt) {
374
- super(`Approval for tool '${toolId}' expired at ${new Date(expiredAt).toISOString()}`);
375
- this.toolId = toolId;
376
- this.expiredAt = expiredAt;
377
- this.name = "ApprovalExpiredError";
378
- }
379
- toolId;
380
- expiredAt;
381
- /**
382
- * Convert to a JSON-RPC compatible error structure.
383
- */
384
- toJsonRpcError() {
385
- return {
386
- code: -32600,
387
- // Invalid Request
388
- message: this.message,
389
- data: {
390
- type: "approval_expired",
391
- toolId: this.toolId,
392
- expiredAt: this.expiredAt
393
- }
394
- };
395
- }
396
- };
397
- var ChallengeValidationError = class extends ApprovalError {
398
- constructor(reason = "invalid", message) {
399
- super(message ?? `PKCE challenge validation failed: ${reason}`);
400
- this.reason = reason;
401
- this.name = "ChallengeValidationError";
402
- }
403
- reason;
404
- /**
405
- * Convert to a JSON-RPC compatible error structure.
406
- */
407
- toJsonRpcError() {
408
- return {
409
- code: -32600,
410
- // Invalid Request
411
- message: this.message,
412
- data: {
413
- type: "challenge_validation_error",
414
- reason: this.reason
415
- }
416
- };
417
- }
418
- };
419
-
420
473
  // plugins/plugin-approval/src/hooks/approval-check.hook.ts
421
474
  var passedApprovalStores = /* @__PURE__ */ new WeakMap();
422
475
  var ApprovalCheckPlugin = class extends DynamicPlugin {
@@ -424,7 +477,7 @@ var ApprovalCheckPlugin = class extends DynamicPlugin {
424
477
  const { tool, toolContext } = flowCtx.state;
425
478
  if (!tool || !toolContext) return;
426
479
  const metadata = tool.metadata;
427
- const approvalConfig = this.resolveApprovalConfig(
480
+ const approvalConfig = resolveApprovalRequirement(
428
481
  metadata["approval"]
429
482
  );
430
483
  if (!approvalConfig.required) {
@@ -442,44 +495,43 @@ var ApprovalCheckPlugin = class extends DynamicPlugin {
442
495
  }
443
496
  async enforceApproval(flowCtx, tool, toolContext, approvalConfig, approvalStore) {
444
497
  const { sessionId, userId } = resolveApprovalIdentity(toolContext.tryGetContext?.());
445
- const approval = await approvalStore.getApproval(tool.fullName, sessionId, userId);
446
- if (approval?.state === "denied" /* DENIED */) {
498
+ const currentContext = this.getCurrentContext(flowCtx);
499
+ const records = await this.readApprovals(approvalStore, tool.fullName, sessionId, userId, currentContext);
500
+ if (records.some((record) => record.state === "denied" /* DENIED */)) {
447
501
  throw new ApprovalRequiredError({
448
502
  toolId: tool.fullName,
449
503
  state: "denied",
450
504
  message: `Tool "${tool.fullName}" execution denied.`
451
505
  });
452
506
  }
453
- if (this.isPreApprovedContext(approvalConfig, this.getCurrentContext(flowCtx))) {
507
+ if (this.isPreApprovedContext(approvalConfig, currentContext)) {
454
508
  return;
455
509
  }
510
+ const approved = records.filter((record) => record.state === "approved" /* APPROVED */);
456
511
  if (approvalConfig.alwaysPrompt) {
457
- await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
512
+ await this.handleApprovalRequired(flowCtx, approvalConfig, approved[0]);
458
513
  return;
459
514
  }
460
- if (approval?.state === "approved" /* APPROVED */) {
461
- if (!this.isExpired(approval)) {
462
- return;
463
- }
515
+ const now = Date.now();
516
+ if (approved.some((record) => isApprovalUsable(record, approvalConfig, now))) {
517
+ return;
464
518
  }
465
- await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
519
+ await this.handleApprovalRequired(
520
+ flowCtx,
521
+ approvalConfig,
522
+ approved.find((record) => isApprovalExpired(record, approvalConfig, now)) ?? approved[0]
523
+ );
466
524
  }
467
- resolveApprovalConfig(config) {
468
- if (config === true) {
469
- return { required: true, defaultScope: "session" /* SESSION */ };
470
- }
471
- if (config === false || config === void 0) {
472
- return { required: false };
525
+ /**
526
+ * The caller's approvals and denials of the tool, from the context it runs in too. Stores
527
+ * without `getApprovals()` give the one record `getApproval()` picks.
528
+ */
529
+ async readApprovals(store, toolId, sessionId, userId, context) {
530
+ if (store.getApprovals) {
531
+ return store.getApprovals(toolId, sessionId, userId, context);
473
532
  }
474
- return {
475
- ...config,
476
- required: config.required ?? true,
477
- defaultScope: config.defaultScope ?? "session" /* SESSION */
478
- };
479
- }
480
- isExpired(approval) {
481
- if (!approval.expiresAt) return false;
482
- return Date.now() > approval.expiresAt;
533
+ const record = await store.getApproval(toolId, sessionId, userId, context);
534
+ return record ? [record] : [];
483
535
  }
484
536
  /**
485
537
  * The context this call is running in, as established by the SESSION (GHSA-r848-p7wf-96rc).
@@ -509,7 +561,7 @@ var ApprovalCheckPlugin = class extends DynamicPlugin {
509
561
  async handleApprovalRequired(flowCtx, config, existingApproval) {
510
562
  const { tool } = flowCtx.state;
511
563
  const message = config.approvalMessage ?? `Tool "${tool?.fullName}" requires approval to execute. Allow?`;
512
- const isExpiredApproval = existingApproval ? this.isExpired(existingApproval) : false;
564
+ const isExpiredApproval = existingApproval ? isApprovalExpired(existingApproval, config, Date.now()) : false;
513
565
  throw new ApprovalRequiredError({
514
566
  toolId: tool?.fullName ?? "unknown",
515
567
  state: isExpiredApproval ? "expired" : "pending",
@@ -525,26 +577,34 @@ var ApprovalCheckPlugin = class extends DynamicPlugin {
525
577
  }
526
578
  };
527
579
  __decorateClass([
528
- ToolHook.Will("execute", { priority: 100 })
580
+ ToolHook.Will("execute", { priority: 100, appliesTo: "uncovered-apps" })
529
581
  ], ApprovalCheckPlugin.prototype, "checkApproval", 1);
530
582
  ApprovalCheckPlugin = __decorateClass([
531
583
  Plugin({
532
584
  name: "approval:check",
533
- description: "Checks tool approval state before execution"
585
+ description: "Checks tool approval state before execution",
586
+ // A server where a tool or agent declares `approval` and this gate does not reach it refuses to start.
587
+ enforcesMetadata: ["approval"]
534
588
  })
535
589
  ], ApprovalCheckPlugin);
536
590
 
537
591
  // plugins/plugin-approval/src/services/approval.service.ts
538
592
  import { Provider, ProviderScope } from "@frontmcp/sdk";
539
593
  var ApprovalService = class {
540
- constructor(store, sessionId, userId) {
594
+ constructor(store, sessionId, userId, requirementOf = () => void 0) {
541
595
  this.store = store;
542
596
  this.sessionId = sessionId;
543
597
  this.userId = userId;
598
+ this.requirementOf = requirementOf;
544
599
  }
545
600
  store;
546
601
  sessionId;
547
602
  userId;
603
+ requirementOf;
604
+ /** Checks a grant against the tool's `allowedScopes` and `maxTtlMs`; returns the ttl to store. */
605
+ checkGrant(toolId, scope, ttlMs) {
606
+ return checkGrantAgainstPolicy(toolId, scope, ttlMs, this.requirementOf(toolId));
607
+ }
548
608
  // ─────────────────────────────────────────────────────────────────────────────
549
609
  // Query Methods
550
610
  // ─────────────────────────────────────────────────────────────────────────────
@@ -552,7 +612,14 @@ var ApprovalService = class {
552
612
  * Check if a tool is approved for current session/user.
553
613
  */
554
614
  async isApproved(toolId, context) {
555
- return this.store.isApproved(toolId, this.sessionId, this.userId, context);
615
+ const requirement = this.requirementOf(toolId);
616
+ if (!requirement || !this.store.getApprovals) {
617
+ return this.store.isApproved(toolId, this.sessionId, this.userId, context);
618
+ }
619
+ const records = await this.store.getApprovals(toolId, this.sessionId, this.userId, context);
620
+ if (records.some((record) => record.state === "denied" /* DENIED */)) return false;
621
+ const now = Date.now();
622
+ return records.some((record) => isApprovalUsable(record, requirement, now));
556
623
  }
557
624
  /**
558
625
  * Get approval record for a tool.
@@ -583,14 +650,15 @@ var ApprovalService = class {
583
650
  });
584
651
  }
585
652
  /**
586
- * Query approvals with custom filters.
653
+ * Query the current caller's approvals (those of its session and those of its user) with custom
654
+ * filters. A `sessionId` or `userId` in the query narrows the result further; it never reaches
655
+ * another caller's records. Read the store directly for administrative queries.
587
656
  */
588
657
  async queryApprovals(query) {
589
- return this.store.queryApprovals({
590
- ...query,
591
- sessionId: query.sessionId ?? this.sessionId,
592
- userId: query.userId ?? this.userId
593
- });
658
+ const records = await this.store.queryApprovals(query);
659
+ return records.filter(
660
+ (record) => record.sessionId === this.sessionId || this.userId !== void 0 && record.userId === this.userId
661
+ );
594
662
  }
595
663
  // ─────────────────────────────────────────────────────────────────────────────
596
664
  // Grant Methods
@@ -599,9 +667,11 @@ var ApprovalService = class {
599
667
  * Grant session-scoped approval for a tool.
600
668
  */
601
669
  async grantSessionApproval(toolId, options = {}) {
670
+ const ttlMs = this.checkGrant(toolId, "session" /* SESSION */);
602
671
  return this.store.grantApproval({
603
672
  toolId,
604
673
  scope: "session" /* SESSION */,
674
+ ttlMs,
605
675
  sessionId: this.sessionId,
606
676
  grantedBy: options.grantedBy ?? "policy",
607
677
  reason: options.reason,
@@ -615,9 +685,11 @@ var ApprovalService = class {
615
685
  if (!this.userId) {
616
686
  throw new Error("Cannot grant user approval without userId");
617
687
  }
688
+ const ttlMs = this.checkGrant(toolId, "user" /* USER */);
618
689
  return this.store.grantApproval({
619
690
  toolId,
620
691
  scope: "user" /* USER */,
692
+ ttlMs,
621
693
  userId: this.userId,
622
694
  grantedBy: options.grantedBy ?? "policy",
623
695
  reason: options.reason,
@@ -628,6 +700,7 @@ var ApprovalService = class {
628
700
  * Grant time-limited approval for a tool.
629
701
  */
630
702
  async grantTimeLimitedApproval(toolId, ttlMs, options = {}) {
703
+ this.checkGrant(toolId, "time_limited" /* TIME_LIMITED */, ttlMs);
631
704
  return this.store.grantApproval({
632
705
  toolId,
633
706
  scope: "time_limited" /* TIME_LIMITED */,
@@ -643,9 +716,11 @@ var ApprovalService = class {
643
716
  * Grant context-specific approval for a tool.
644
717
  */
645
718
  async grantContextApproval(toolId, context, options = {}) {
719
+ const ttlMs = this.checkGrant(toolId, "context_specific" /* CONTEXT_SPECIFIC */);
646
720
  return this.store.grantApproval({
647
721
  toolId,
648
722
  scope: "context_specific" /* CONTEXT_SPECIFIC */,
723
+ ttlMs,
649
724
  context,
650
725
  sessionId: this.sessionId,
651
726
  userId: this.userId,
@@ -658,7 +733,8 @@ var ApprovalService = class {
658
733
  // Revoke Methods
659
734
  // ─────────────────────────────────────────────────────────────────────────────
660
735
  /**
661
- * Revoke approval for a tool.
736
+ * Revoke the caller's approvals of a tool: its session, user, time-limited and context
737
+ * approvals. Returns whether anything was revoked. Recorded denials are kept.
662
738
  */
663
739
  async revokeApproval(toolId, options = {}) {
664
740
  return this.store.revokeApproval({
@@ -683,8 +759,8 @@ ApprovalService = __decorateClass([
683
759
  scope: ProviderScope.CONTEXT
684
760
  })
685
761
  ], ApprovalService);
686
- function createApprovalService(store, sessionId, userId) {
687
- return new ApprovalService(store, sessionId, userId);
762
+ function createApprovalService(store, sessionId, userId, requirementOf) {
763
+ return new ApprovalService(store, sessionId, userId, requirementOf);
688
764
  }
689
765
 
690
766
  // plugins/plugin-approval/src/services/challenge.service.ts
@@ -856,6 +932,7 @@ import {
856
932
  function escapePattern(str) {
857
933
  return str.replace(/[*?[\]\\]/g, "\\$&");
858
934
  }
935
+ var TIME_LIMITED_KEY_SEGMENT = `scope:${"time_limited" /* TIME_LIMITED */}`;
859
936
  var ApprovalStorageStore = class {
860
937
  storage;
861
938
  options;
@@ -905,6 +982,22 @@ var ApprovalStorageStore = class {
905
982
  if (context) parts.push(`ctx:${context.type}:${context.identifier}`);
906
983
  return parts.join(":");
907
984
  }
985
+ /**
986
+ * The key of a time-limited approval granted to a session alone or a user alone. Kept apart from
987
+ * that session's or user's own key, so a time-limited grant never replaces the session or user
988
+ * approval, or a denial recorded there. (With both a session and a user, `buildKey` is already distinct.)
989
+ */
990
+ timeLimitedKey(toolId, sessionId, userId) {
991
+ return `${this.buildKey(toolId, sessionId, userId)}:${TIME_LIMITED_KEY_SEGMENT}`;
992
+ }
993
+ /** The key a new record is stored under. */
994
+ recordKey(options) {
995
+ const { toolId, scope, sessionId, userId, context } = options;
996
+ if (scope === "time_limited" /* TIME_LIMITED */ && !context && !(sessionId && userId)) {
997
+ return this.timeLimitedKey(toolId, sessionId, userId);
998
+ }
999
+ return this.buildKey(toolId, sessionId, userId, context);
1000
+ }
908
1001
  parseRecord(value) {
909
1002
  if (!value) return void 0;
910
1003
  try {
@@ -922,28 +1015,55 @@ var ApprovalStorageStore = class {
922
1015
  return approval.expiresAt !== void 0 && Date.now() > approval.expiresAt;
923
1016
  }
924
1017
  /**
925
- * The session record, else the user record, except that a denial in either scope wins,
926
- * so a session approval cannot mask a user-level denial.
1018
+ * The keys a caller's approvals of a tool can be stored under: its session, its user, both
1019
+ * (time-limited and context grants made through `ApprovalService`), the time-limited keys of its
1020
+ * session and its user, and, for a call in a server-established context, that context (granted
1021
+ * to the session and the user, or to the session before it had a user, the way a session approval
1022
+ * stays in force once the caller's user is known). Records written by 1.8.2 sit under the same
1023
+ * keys (a time-limited grant without a user under the session key), so they are still read.
927
1024
  */
928
- async getApproval(toolId, sessionId, userId) {
929
- this.ensureInitialized();
930
- const keys = [this.buildKey(toolId, sessionId)];
1025
+ callerKeys(toolId, sessionId, userId, context) {
1026
+ const keys = [this.buildKey(toolId, sessionId), this.timeLimitedKey(toolId, sessionId)];
931
1027
  if (userId) {
932
- keys.push(this.buildKey(toolId, void 0, userId));
1028
+ keys.push(
1029
+ this.buildKey(toolId, void 0, userId),
1030
+ this.timeLimitedKey(toolId, void 0, userId),
1031
+ this.buildKey(toolId, sessionId, userId)
1032
+ );
933
1033
  }
934
- const activeRecords = [];
935
- for (const key of keys) {
1034
+ if (context) {
1035
+ keys.push(this.buildKey(toolId, sessionId, void 0, context));
1036
+ if (userId) keys.push(this.buildKey(toolId, sessionId, userId, context));
1037
+ }
1038
+ return [...new Set(keys)];
1039
+ }
1040
+ /**
1041
+ * Every unexpired record that applies to this caller: session, user, time-limited and, when
1042
+ * `context` is given, context-specific approvals and denials.
1043
+ */
1044
+ async getApprovals(toolId, sessionId, userId, context) {
1045
+ this.ensureInitialized();
1046
+ const records = [];
1047
+ for (const key of this.callerKeys(toolId, sessionId, userId, context)) {
936
1048
  const record = this.parseRecord(await this.storage.get(key));
937
- if (record && !this.isExpired(record)) {
938
- activeRecords.push(record);
1049
+ if (record && record.toolId === toolId && !this.isExpired(record)) {
1050
+ records.push(record);
939
1051
  }
940
1052
  }
941
- return activeRecords.find((record) => record.state === "denied" /* DENIED */) ?? activeRecords[0];
1053
+ return records;
1054
+ }
1055
+ /**
1056
+ * The caller's record for a tool; a denial in any of its scopes wins, so an approval in one
1057
+ * scope cannot mask a denial in another.
1058
+ */
1059
+ async getApproval(toolId, sessionId, userId, context) {
1060
+ const records = await this.getApprovals(toolId, sessionId, userId, context);
1061
+ return records.find((record) => record.state === "denied" /* DENIED */) ?? records[0];
942
1062
  }
943
1063
  async queryApprovals(query) {
944
1064
  this.ensureInitialized();
945
1065
  const results = [];
946
- const pattern = query.toolId ? `${query.toolId}:*` : "*";
1066
+ const pattern = query.toolId ? `${escapePattern(query.toolId)}:*` : "*";
947
1067
  const keys = await this.storage.keys(pattern);
948
1068
  const values = await this.storage.mget(keys);
949
1069
  for (const value of values) {
@@ -971,8 +1091,12 @@ var ApprovalStorageStore = class {
971
1091
  }
972
1092
  async grantApproval(options) {
973
1093
  this.ensureInitialized();
1094
+ assertValidTtl(options.ttlMs);
1095
+ if (options.scope === "time_limited" /* TIME_LIMITED */ && options.ttlMs === void 0) {
1096
+ throw new ApprovalOperationError("grant", "a time-limited approval needs ttlMs");
1097
+ }
974
1098
  const now = Date.now();
975
- const expiresAt = options.ttlMs ? now + options.ttlMs : void 0;
1099
+ const expiresAt = options.ttlMs !== void 0 ? now + options.ttlMs : void 0;
976
1100
  const grantedBy = normalizeGrantor(options.grantedBy);
977
1101
  const record = {
978
1102
  toolId: options.toolId,
@@ -988,52 +1112,59 @@ var ApprovalStorageStore = class {
988
1112
  reason: options.reason,
989
1113
  metadata: options.metadata
990
1114
  };
991
- const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
992
- const ttlSeconds = options.ttlMs ? Math.ceil(options.ttlMs / 1e3) : void 0;
1115
+ const key = this.recordKey(options);
1116
+ const ttlSeconds = options.ttlMs !== void 0 ? Math.ceil(options.ttlMs / 1e3) : void 0;
993
1117
  await this.storage.set(key, JSON.stringify(record), { ttlSeconds });
994
1118
  return record;
995
1119
  }
1120
+ /**
1121
+ * Deletes the approvals of a tool that belong to the given session or user: with a context, only
1122
+ * that context's approval; otherwise every approval of the tool stored for that session or user
1123
+ * (session, user, time-limited and context approvals alike). Recorded denials are kept.
1124
+ */
996
1125
  async revokeApproval(options) {
997
1126
  this.ensureInitialized();
998
- const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
999
- const exists = await this.storage.exists(key);
1000
- if (exists) {
1001
- await this.storage.delete(key);
1002
- return true;
1127
+ const { toolId, sessionId, userId, context } = options;
1128
+ if (!sessionId && !userId && !context) {
1129
+ return false;
1003
1130
  }
1004
- return false;
1005
- }
1006
- async isApproved(toolId, sessionId, userId, context) {
1007
- this.ensureInitialized();
1008
- if (context) {
1009
- const contextKey = this.buildKey(toolId, sessionId, userId, context);
1010
- const contextValue = await this.storage.get(contextKey);
1011
- const contextApproval = this.parseRecord(contextValue);
1012
- if (contextApproval && contextApproval.state === "approved" /* APPROVED */ && !this.isExpired(contextApproval)) {
1013
- return true;
1131
+ const keys = await this.storage.keys(`${escapePattern(toolId)}:*`);
1132
+ const values = await this.storage.mget(keys);
1133
+ const keysToDelete = [];
1134
+ for (let i = 0; i < keys.length; i++) {
1135
+ const record = this.parseRecord(values[i]);
1136
+ if (!record || record.toolId !== toolId || record.state === "denied" /* DENIED */) continue;
1137
+ if (context) {
1138
+ const sameSession = sessionId !== void 0 && record.sessionId === sessionId;
1139
+ 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.
1140
+ (!userId || record.userId === userId || sameSession && record.userId === void 0)) {
1141
+ keysToDelete.push(keys[i]);
1142
+ }
1143
+ continue;
1014
1144
  }
1015
- }
1016
- const sessionKey = this.buildKey(toolId, sessionId);
1017
- const sessionValue = await this.storage.get(sessionKey);
1018
- const sessionApproval = this.parseRecord(sessionValue);
1019
- if (sessionApproval && sessionApproval.state === "approved" /* APPROVED */ && !this.isExpired(sessionApproval)) {
1020
- return true;
1021
- }
1022
- if (userId) {
1023
- const userKey = this.buildKey(toolId, void 0, userId);
1024
- const userValue = await this.storage.get(userKey);
1025
- const userApproval = this.parseRecord(userValue);
1026
- if (userApproval && userApproval.state === "approved" /* APPROVED */ && !this.isExpired(userApproval)) {
1027
- return true;
1145
+ if (sessionId && record.sessionId === sessionId || userId && record.userId === userId) {
1146
+ keysToDelete.push(keys[i]);
1028
1147
  }
1029
1148
  }
1030
- return false;
1149
+ if (keysToDelete.length === 0) {
1150
+ return false;
1151
+ }
1152
+ await this.storage.mdelete(keysToDelete);
1153
+ return true;
1154
+ }
1155
+ async isApproved(toolId, sessionId, userId, context) {
1156
+ const approval = await this.getApproval(toolId, sessionId, userId, context);
1157
+ return approval?.state === "approved" /* APPROVED */;
1031
1158
  }
1032
1159
  async clearSessionApprovals(sessionId) {
1033
1160
  this.ensureInitialized();
1034
1161
  const escapedSessionId = escapePattern(sessionId);
1035
- const pattern = `*:session:${escapedSessionId}*`;
1036
- const keys = await this.storage.keys(pattern);
1162
+ const keys = [
1163
+ .../* @__PURE__ */ new Set([
1164
+ ...await this.storage.keys(`*:session:${escapedSessionId}`),
1165
+ ...await this.storage.keys(`*:session:${escapedSessionId}:*`)
1166
+ ])
1167
+ ];
1037
1168
  if (keys.length === 0) {
1038
1169
  return 0;
1039
1170
  }
@@ -1177,10 +1308,16 @@ __publicField(ApprovalPlugin, "dynamicProviders", (options) => {
1177
1308
  name: "approval:service",
1178
1309
  provide: ApprovalServiceToken,
1179
1310
  scope: ProviderScope4.CONTEXT,
1180
- inject: () => [ApprovalStoreToken, FRONTMCP_CONTEXT],
1181
- useFactory: (store, ctx) => {
1311
+ inject: () => [ApprovalStoreToken, FRONTMCP_CONTEXT, ScopeEntry],
1312
+ useFactory: (store, ctx, scope) => {
1182
1313
  const { sessionId, userId } = resolveApprovalIdentity(ctx);
1183
- return createApprovalService(store, sessionId, userId);
1314
+ const requirementOf = (toolId) => {
1315
+ const tool = scope.tools.getTools(true).find((entry) => entry.fullName === toolId);
1316
+ if (!tool) return void 0;
1317
+ const approval = tool.metadata.approval;
1318
+ return resolveApprovalRequirement(approval);
1319
+ };
1320
+ return createApprovalService(store, sessionId, userId, requirementOf);
1184
1321
  }
1185
1322
  });
1186
1323
  return providers;