@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/esm/index.mjs CHANGED
@@ -22,11 +22,210 @@ 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
- import { STATELESS_SESSION_ID } from "@frontmcp/sdk";
30
229
  import { randomUUID } from "@frontmcp/utils";
31
230
  function nonEmptyString(value) {
32
231
  return typeof value === "string" && value.length > 0 ? value : void 0;
@@ -34,8 +233,8 @@ function nonEmptyString(value) {
34
233
  function resolveApprovalIdentity(ctx) {
35
234
  const extra = ctx?.authInfo?.extra;
36
235
  const userId = nonEmptyString(extra?.["userId"]) ?? nonEmptyString(extra?.["sub"]) ?? nonEmptyString(ctx?.authInfo?.clientId);
37
- const sessionId = nonEmptyString(ctx?.sessionId);
38
- if (sessionId && sessionId !== STATELESS_SESSION_ID) {
236
+ const sessionId = nonEmptyString(ctx?.verifiedSessionId);
237
+ if (sessionId) {
39
238
  return { sessionId, userId };
40
239
  }
41
240
  return { sessionId: userId ? `stateless-user:${userId}` : `unidentified:${randomUUID()}`, userId };
@@ -55,23 +254,6 @@ var ChallengeServiceToken = /* @__PURE__ */ Symbol.for(
55
254
  // plugins/plugin-approval/src/hooks/approval-check.hook.ts
56
255
  import { DynamicPlugin, Plugin, ToolHook } from "@frontmcp/sdk";
57
256
 
58
- // plugins/plugin-approval/src/approval/types.ts
59
- var ApprovalScope = /* @__PURE__ */ ((ApprovalScope2) => {
60
- ApprovalScope2["SESSION"] = "session";
61
- ApprovalScope2["USER"] = "user";
62
- ApprovalScope2["TIME_LIMITED"] = "time_limited";
63
- ApprovalScope2["TOOL_SPECIFIC"] = "tool_specific";
64
- ApprovalScope2["CONTEXT_SPECIFIC"] = "context_specific";
65
- return ApprovalScope2;
66
- })(ApprovalScope || {});
67
- var ApprovalState = /* @__PURE__ */ ((ApprovalState2) => {
68
- ApprovalState2["PENDING"] = "pending";
69
- ApprovalState2["APPROVED"] = "approved";
70
- ApprovalState2["DENIED"] = "denied";
71
- ApprovalState2["EXPIRED"] = "expired";
72
- return ApprovalState2;
73
- })(ApprovalState || {});
74
-
75
257
  // plugins/plugin-approval/src/approval/schemas.ts
76
258
  import { z } from "@frontmcp/lazy-zod";
77
259
  var approvalScopeSchema = z.nativeEnum(ApprovalScope);
@@ -288,136 +470,6 @@ function isApiGrantor(grantor) {
288
470
  return grantor.source === "api" || grantor.source === "oauth";
289
471
  }
290
472
 
291
- // plugins/plugin-approval/src/approval/errors.ts
292
- var ApprovalError = class extends Error {
293
- constructor(message) {
294
- super(message);
295
- this.name = "ApprovalError";
296
- }
297
- };
298
- var ApprovalRequiredError = class extends ApprovalError {
299
- constructor(details) {
300
- super(details.message);
301
- this.details = details;
302
- this.name = "ApprovalRequiredError";
303
- }
304
- details;
305
- /**
306
- * Convert to a JSON-RPC compatible error structure.
307
- */
308
- toJsonRpcError() {
309
- return {
310
- code: -32600,
311
- // Invalid Request
312
- message: this.details.message,
313
- data: {
314
- type: "approval_required",
315
- toolId: this.details.toolId,
316
- state: this.details.state,
317
- options: this.details.approvalOptions
318
- }
319
- };
320
- }
321
- };
322
- var ApprovalOperationError = class extends ApprovalError {
323
- constructor(operation, reason) {
324
- super(`Approval ${operation} failed: ${reason}`);
325
- this.operation = operation;
326
- this.reason = reason;
327
- this.name = "ApprovalOperationError";
328
- }
329
- operation;
330
- reason;
331
- /**
332
- * Convert to a JSON-RPC compatible error structure.
333
- */
334
- toJsonRpcError() {
335
- return {
336
- code: -32603,
337
- // Internal Error
338
- message: "Approval operation failed",
339
- data: {
340
- type: "approval_operation_error",
341
- operation: this.operation
342
- }
343
- };
344
- }
345
- };
346
- var ApprovalScopeNotAllowedError = class extends ApprovalError {
347
- constructor(requestedScope, allowedScopes) {
348
- super(
349
- `Approval scope '${requestedScope}' is not allowed for this tool. Allowed scopes: ${allowedScopes.join(", ")}`
350
- );
351
- this.requestedScope = requestedScope;
352
- this.allowedScopes = allowedScopes;
353
- this.name = "ApprovalScopeNotAllowedError";
354
- }
355
- requestedScope;
356
- allowedScopes;
357
- /**
358
- * Convert to a JSON-RPC compatible error structure.
359
- */
360
- toJsonRpcError() {
361
- return {
362
- code: -32602,
363
- // Invalid Params
364
- message: this.message,
365
- data: {
366
- type: "approval_scope_not_allowed",
367
- requestedScope: this.requestedScope,
368
- allowedScopes: this.allowedScopes
369
- }
370
- };
371
- }
372
- };
373
- var ApprovalExpiredError = class extends ApprovalError {
374
- constructor(toolId, expiredAt) {
375
- super(`Approval for tool '${toolId}' expired at ${new Date(expiredAt).toISOString()}`);
376
- this.toolId = toolId;
377
- this.expiredAt = expiredAt;
378
- this.name = "ApprovalExpiredError";
379
- }
380
- toolId;
381
- expiredAt;
382
- /**
383
- * Convert to a JSON-RPC compatible error structure.
384
- */
385
- toJsonRpcError() {
386
- return {
387
- code: -32600,
388
- // Invalid Request
389
- message: this.message,
390
- data: {
391
- type: "approval_expired",
392
- toolId: this.toolId,
393
- expiredAt: this.expiredAt
394
- }
395
- };
396
- }
397
- };
398
- var ChallengeValidationError = class extends ApprovalError {
399
- constructor(reason = "invalid", message) {
400
- super(message ?? `PKCE challenge validation failed: ${reason}`);
401
- this.reason = reason;
402
- this.name = "ChallengeValidationError";
403
- }
404
- reason;
405
- /**
406
- * Convert to a JSON-RPC compatible error structure.
407
- */
408
- toJsonRpcError() {
409
- return {
410
- code: -32600,
411
- // Invalid Request
412
- message: this.message,
413
- data: {
414
- type: "challenge_validation_error",
415
- reason: this.reason
416
- }
417
- };
418
- }
419
- };
420
-
421
473
  // plugins/plugin-approval/src/hooks/approval-check.hook.ts
422
474
  var passedApprovalStores = /* @__PURE__ */ new WeakMap();
423
475
  var ApprovalCheckPlugin = class extends DynamicPlugin {
@@ -425,7 +477,7 @@ var ApprovalCheckPlugin = class extends DynamicPlugin {
425
477
  const { tool, toolContext } = flowCtx.state;
426
478
  if (!tool || !toolContext) return;
427
479
  const metadata = tool.metadata;
428
- const approvalConfig = this.resolveApprovalConfig(
480
+ const approvalConfig = resolveApprovalRequirement(
429
481
  metadata["approval"]
430
482
  );
431
483
  if (!approvalConfig.required) {
@@ -443,44 +495,43 @@ var ApprovalCheckPlugin = class extends DynamicPlugin {
443
495
  }
444
496
  async enforceApproval(flowCtx, tool, toolContext, approvalConfig, approvalStore) {
445
497
  const { sessionId, userId } = resolveApprovalIdentity(toolContext.tryGetContext?.());
446
- const approval = await approvalStore.getApproval(tool.fullName, sessionId, userId);
447
- 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 */)) {
448
501
  throw new ApprovalRequiredError({
449
502
  toolId: tool.fullName,
450
503
  state: "denied",
451
504
  message: `Tool "${tool.fullName}" execution denied.`
452
505
  });
453
506
  }
454
- if (this.isPreApprovedContext(approvalConfig, this.getCurrentContext(flowCtx))) {
507
+ if (this.isPreApprovedContext(approvalConfig, currentContext)) {
455
508
  return;
456
509
  }
510
+ const approved = records.filter((record) => record.state === "approved" /* APPROVED */);
457
511
  if (approvalConfig.alwaysPrompt) {
458
- await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
512
+ await this.handleApprovalRequired(flowCtx, approvalConfig, approved[0]);
459
513
  return;
460
514
  }
461
- if (approval?.state === "approved" /* APPROVED */) {
462
- if (!this.isExpired(approval)) {
463
- return;
464
- }
515
+ const now = Date.now();
516
+ if (approved.some((record) => isApprovalUsable(record, approvalConfig, now))) {
517
+ return;
465
518
  }
466
- 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
+ );
467
524
  }
468
- resolveApprovalConfig(config) {
469
- if (config === true) {
470
- return { required: true, defaultScope: "session" /* SESSION */ };
471
- }
472
- if (config === false || config === void 0) {
473
- 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);
474
532
  }
475
- return {
476
- ...config,
477
- required: config.required ?? true,
478
- defaultScope: config.defaultScope ?? "session" /* SESSION */
479
- };
480
- }
481
- isExpired(approval) {
482
- if (!approval.expiresAt) return false;
483
- return Date.now() > approval.expiresAt;
533
+ const record = await store.getApproval(toolId, sessionId, userId, context);
534
+ return record ? [record] : [];
484
535
  }
485
536
  /**
486
537
  * The context this call is running in, as established by the SESSION (GHSA-r848-p7wf-96rc).
@@ -510,7 +561,7 @@ var ApprovalCheckPlugin = class extends DynamicPlugin {
510
561
  async handleApprovalRequired(flowCtx, config, existingApproval) {
511
562
  const { tool } = flowCtx.state;
512
563
  const message = config.approvalMessage ?? `Tool "${tool?.fullName}" requires approval to execute. Allow?`;
513
- const isExpiredApproval = existingApproval ? this.isExpired(existingApproval) : false;
564
+ const isExpiredApproval = existingApproval ? isApprovalExpired(existingApproval, config, Date.now()) : false;
514
565
  throw new ApprovalRequiredError({
515
566
  toolId: tool?.fullName ?? "unknown",
516
567
  state: isExpiredApproval ? "expired" : "pending",
@@ -526,26 +577,34 @@ var ApprovalCheckPlugin = class extends DynamicPlugin {
526
577
  }
527
578
  };
528
579
  __decorateClass([
529
- ToolHook.Will("execute", { priority: 100 })
580
+ ToolHook.Will("execute", { priority: 100, appliesTo: "uncovered-apps" })
530
581
  ], ApprovalCheckPlugin.prototype, "checkApproval", 1);
531
582
  ApprovalCheckPlugin = __decorateClass([
532
583
  Plugin({
533
584
  name: "approval:check",
534
- 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"]
535
588
  })
536
589
  ], ApprovalCheckPlugin);
537
590
 
538
591
  // plugins/plugin-approval/src/services/approval.service.ts
539
592
  import { Provider, ProviderScope } from "@frontmcp/sdk";
540
593
  var ApprovalService = class {
541
- constructor(store, sessionId, userId) {
594
+ constructor(store, sessionId, userId, requirementOf = () => void 0) {
542
595
  this.store = store;
543
596
  this.sessionId = sessionId;
544
597
  this.userId = userId;
598
+ this.requirementOf = requirementOf;
545
599
  }
546
600
  store;
547
601
  sessionId;
548
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
+ }
549
608
  // ─────────────────────────────────────────────────────────────────────────────
550
609
  // Query Methods
551
610
  // ─────────────────────────────────────────────────────────────────────────────
@@ -553,7 +612,14 @@ var ApprovalService = class {
553
612
  * Check if a tool is approved for current session/user.
554
613
  */
555
614
  async isApproved(toolId, context) {
556
- 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));
557
623
  }
558
624
  /**
559
625
  * Get approval record for a tool.
@@ -584,14 +650,15 @@ var ApprovalService = class {
584
650
  });
585
651
  }
586
652
  /**
587
- * 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.
588
656
  */
589
657
  async queryApprovals(query) {
590
- return this.store.queryApprovals({
591
- ...query,
592
- sessionId: query.sessionId ?? this.sessionId,
593
- userId: query.userId ?? this.userId
594
- });
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
+ );
595
662
  }
596
663
  // ─────────────────────────────────────────────────────────────────────────────
597
664
  // Grant Methods
@@ -600,9 +667,11 @@ var ApprovalService = class {
600
667
  * Grant session-scoped approval for a tool.
601
668
  */
602
669
  async grantSessionApproval(toolId, options = {}) {
670
+ const ttlMs = this.checkGrant(toolId, "session" /* SESSION */);
603
671
  return this.store.grantApproval({
604
672
  toolId,
605
673
  scope: "session" /* SESSION */,
674
+ ttlMs,
606
675
  sessionId: this.sessionId,
607
676
  grantedBy: options.grantedBy ?? "policy",
608
677
  reason: options.reason,
@@ -616,9 +685,11 @@ var ApprovalService = class {
616
685
  if (!this.userId) {
617
686
  throw new Error("Cannot grant user approval without userId");
618
687
  }
688
+ const ttlMs = this.checkGrant(toolId, "user" /* USER */);
619
689
  return this.store.grantApproval({
620
690
  toolId,
621
691
  scope: "user" /* USER */,
692
+ ttlMs,
622
693
  userId: this.userId,
623
694
  grantedBy: options.grantedBy ?? "policy",
624
695
  reason: options.reason,
@@ -629,6 +700,7 @@ var ApprovalService = class {
629
700
  * Grant time-limited approval for a tool.
630
701
  */
631
702
  async grantTimeLimitedApproval(toolId, ttlMs, options = {}) {
703
+ this.checkGrant(toolId, "time_limited" /* TIME_LIMITED */, ttlMs);
632
704
  return this.store.grantApproval({
633
705
  toolId,
634
706
  scope: "time_limited" /* TIME_LIMITED */,
@@ -644,9 +716,11 @@ var ApprovalService = class {
644
716
  * Grant context-specific approval for a tool.
645
717
  */
646
718
  async grantContextApproval(toolId, context, options = {}) {
719
+ const ttlMs = this.checkGrant(toolId, "context_specific" /* CONTEXT_SPECIFIC */);
647
720
  return this.store.grantApproval({
648
721
  toolId,
649
722
  scope: "context_specific" /* CONTEXT_SPECIFIC */,
723
+ ttlMs,
650
724
  context,
651
725
  sessionId: this.sessionId,
652
726
  userId: this.userId,
@@ -659,7 +733,8 @@ var ApprovalService = class {
659
733
  // Revoke Methods
660
734
  // ─────────────────────────────────────────────────────────────────────────────
661
735
  /**
662
- * 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.
663
738
  */
664
739
  async revokeApproval(toolId, options = {}) {
665
740
  return this.store.revokeApproval({
@@ -684,8 +759,8 @@ ApprovalService = __decorateClass([
684
759
  scope: ProviderScope.CONTEXT
685
760
  })
686
761
  ], ApprovalService);
687
- function createApprovalService(store, sessionId, userId) {
688
- return new ApprovalService(store, sessionId, userId);
762
+ function createApprovalService(store, sessionId, userId, requirementOf) {
763
+ return new ApprovalService(store, sessionId, userId, requirementOf);
689
764
  }
690
765
 
691
766
  // plugins/plugin-approval/src/services/challenge.service.ts
@@ -857,6 +932,7 @@ import {
857
932
  function escapePattern(str) {
858
933
  return str.replace(/[*?[\]\\]/g, "\\$&");
859
934
  }
935
+ var TIME_LIMITED_KEY_SEGMENT = `scope:${"time_limited" /* TIME_LIMITED */}`;
860
936
  var ApprovalStorageStore = class {
861
937
  storage;
862
938
  options;
@@ -906,6 +982,22 @@ var ApprovalStorageStore = class {
906
982
  if (context) parts.push(`ctx:${context.type}:${context.identifier}`);
907
983
  return parts.join(":");
908
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
+ }
909
1001
  parseRecord(value) {
910
1002
  if (!value) return void 0;
911
1003
  try {
@@ -923,28 +1015,55 @@ var ApprovalStorageStore = class {
923
1015
  return approval.expiresAt !== void 0 && Date.now() > approval.expiresAt;
924
1016
  }
925
1017
  /**
926
- * The session record, else the user record, except that a denial in either scope wins,
927
- * so a session approval cannot mask a user-level denial.
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.
928
1024
  */
929
- async getApproval(toolId, sessionId, userId) {
930
- this.ensureInitialized();
931
- const keys = [this.buildKey(toolId, sessionId)];
1025
+ callerKeys(toolId, sessionId, userId, context) {
1026
+ const keys = [this.buildKey(toolId, sessionId), this.timeLimitedKey(toolId, sessionId)];
932
1027
  if (userId) {
933
- 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
+ );
934
1033
  }
935
- const activeRecords = [];
936
- 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)) {
937
1048
  const record = this.parseRecord(await this.storage.get(key));
938
- if (record && !this.isExpired(record)) {
939
- activeRecords.push(record);
1049
+ if (record && record.toolId === toolId && !this.isExpired(record)) {
1050
+ records.push(record);
940
1051
  }
941
1052
  }
942
- 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];
943
1062
  }
944
1063
  async queryApprovals(query) {
945
1064
  this.ensureInitialized();
946
1065
  const results = [];
947
- const pattern = query.toolId ? `${query.toolId}:*` : "*";
1066
+ const pattern = query.toolId ? `${escapePattern(query.toolId)}:*` : "*";
948
1067
  const keys = await this.storage.keys(pattern);
949
1068
  const values = await this.storage.mget(keys);
950
1069
  for (const value of values) {
@@ -972,8 +1091,12 @@ var ApprovalStorageStore = class {
972
1091
  }
973
1092
  async grantApproval(options) {
974
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
+ }
975
1098
  const now = Date.now();
976
- const expiresAt = options.ttlMs ? now + options.ttlMs : void 0;
1099
+ const expiresAt = options.ttlMs !== void 0 ? now + options.ttlMs : void 0;
977
1100
  const grantedBy = normalizeGrantor(options.grantedBy);
978
1101
  const record = {
979
1102
  toolId: options.toolId,
@@ -989,52 +1112,59 @@ var ApprovalStorageStore = class {
989
1112
  reason: options.reason,
990
1113
  metadata: options.metadata
991
1114
  };
992
- const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
993
- const ttlSeconds = options.ttlMs ? Math.ceil(options.ttlMs / 1e3) : void 0;
1115
+ const key = this.recordKey(options);
1116
+ const ttlSeconds = options.ttlMs !== void 0 ? Math.ceil(options.ttlMs / 1e3) : void 0;
994
1117
  await this.storage.set(key, JSON.stringify(record), { ttlSeconds });
995
1118
  return record;
996
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
+ */
997
1125
  async revokeApproval(options) {
998
1126
  this.ensureInitialized();
999
- const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
1000
- const exists = await this.storage.exists(key);
1001
- if (exists) {
1002
- await this.storage.delete(key);
1003
- return true;
1127
+ const { toolId, sessionId, userId, context } = options;
1128
+ if (!sessionId && !userId && !context) {
1129
+ return false;
1004
1130
  }
1005
- return false;
1006
- }
1007
- async isApproved(toolId, sessionId, userId, context) {
1008
- this.ensureInitialized();
1009
- if (context) {
1010
- const contextKey = this.buildKey(toolId, sessionId, userId, context);
1011
- const contextValue = await this.storage.get(contextKey);
1012
- const contextApproval = this.parseRecord(contextValue);
1013
- if (contextApproval && contextApproval.state === "approved" /* APPROVED */ && !this.isExpired(contextApproval)) {
1014
- return true;
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;
1015
1144
  }
1016
- }
1017
- const sessionKey = this.buildKey(toolId, sessionId);
1018
- const sessionValue = await this.storage.get(sessionKey);
1019
- const sessionApproval = this.parseRecord(sessionValue);
1020
- if (sessionApproval && sessionApproval.state === "approved" /* APPROVED */ && !this.isExpired(sessionApproval)) {
1021
- return true;
1022
- }
1023
- if (userId) {
1024
- const userKey = this.buildKey(toolId, void 0, userId);
1025
- const userValue = await this.storage.get(userKey);
1026
- const userApproval = this.parseRecord(userValue);
1027
- if (userApproval && userApproval.state === "approved" /* APPROVED */ && !this.isExpired(userApproval)) {
1028
- return true;
1145
+ if (sessionId && record.sessionId === sessionId || userId && record.userId === userId) {
1146
+ keysToDelete.push(keys[i]);
1029
1147
  }
1030
1148
  }
1031
- 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 */;
1032
1158
  }
1033
1159
  async clearSessionApprovals(sessionId) {
1034
1160
  this.ensureInitialized();
1035
1161
  const escapedSessionId = escapePattern(sessionId);
1036
- const pattern = `*:session:${escapedSessionId}*`;
1037
- 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
+ ];
1038
1168
  if (keys.length === 0) {
1039
1169
  return 0;
1040
1170
  }
@@ -1178,10 +1308,16 @@ __publicField(ApprovalPlugin, "dynamicProviders", (options) => {
1178
1308
  name: "approval:service",
1179
1309
  provide: ApprovalServiceToken,
1180
1310
  scope: ProviderScope4.CONTEXT,
1181
- inject: () => [ApprovalStoreToken, FRONTMCP_CONTEXT],
1182
- useFactory: (store, ctx) => {
1311
+ inject: () => [ApprovalStoreToken, FRONTMCP_CONTEXT, ScopeEntry],
1312
+ useFactory: (store, ctx, scope) => {
1183
1313
  const { sessionId, userId } = resolveApprovalIdentity(ctx);
1184
- 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);
1185
1321
  }
1186
1322
  });
1187
1323
  return providers;