@frontmcp/plugin-approval 1.8.0 → 1.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/approval.identity.d.ts +22 -0
- package/approval.identity.d.ts.map +1 -0
- package/approval.plugin.d.ts +2 -8
- package/approval.plugin.d.ts.map +1 -1
- package/esm/index.mjs +387 -371
- package/esm/package.json +4 -4
- package/hooks/approval-check.hook.d.ts +3 -2
- package/hooks/approval-check.hook.d.ts.map +1 -1
- package/index.js +365 -354
- package/package.json +4 -4
- package/stores/approval-storage.store.d.ts +7 -3
- package/stores/approval-storage.store.d.ts.map +1 -1
- package/types/approval.types.d.ts +7 -0
- package/types/approval.types.d.ts.map +1 -1
package/esm/index.mjs
CHANGED
|
@@ -18,14 +18,41 @@ var __decorateClass = (decorators, target, key, kind) => {
|
|
|
18
18
|
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
19
19
|
|
|
20
20
|
// plugins/plugin-approval/src/approval.plugin.ts
|
|
21
|
-
import { DynamicPlugin as DynamicPlugin2, Plugin as Plugin2, ProviderScope as ProviderScope4, FRONTMCP_CONTEXT } from "@frontmcp/sdk";
|
|
22
|
-
|
|
23
|
-
// plugins/plugin-approval/src/stores/approval-storage.store.ts
|
|
24
|
-
import { Provider, ProviderScope } from "@frontmcp/sdk";
|
|
25
21
|
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
DynamicPlugin as DynamicPlugin2,
|
|
23
|
+
FRONTMCP_CONTEXT,
|
|
24
|
+
Plugin as Plugin2,
|
|
25
|
+
ProviderScope as ProviderScope4
|
|
26
|
+
} from "@frontmcp/sdk";
|
|
27
|
+
|
|
28
|
+
// plugins/plugin-approval/src/approval.identity.ts
|
|
29
|
+
import { randomUUID } from "@frontmcp/utils";
|
|
30
|
+
function nonEmptyString(value) {
|
|
31
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
32
|
+
}
|
|
33
|
+
function resolveApprovalIdentity(ctx) {
|
|
34
|
+
const extra = ctx?.authInfo?.extra;
|
|
35
|
+
const userId = nonEmptyString(extra?.["userId"]) ?? nonEmptyString(extra?.["sub"]) ?? nonEmptyString(ctx?.authInfo?.clientId);
|
|
36
|
+
const sessionId = nonEmptyString(ctx?.verifiedSessionId);
|
|
37
|
+
if (sessionId) {
|
|
38
|
+
return { sessionId, userId };
|
|
39
|
+
}
|
|
40
|
+
return { sessionId: userId ? `stateless-user:${userId}` : `unidentified:${randomUUID()}`, userId };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// plugins/plugin-approval/src/approval.symbols.ts
|
|
44
|
+
var ApprovalStoreToken = /* @__PURE__ */ Symbol.for(
|
|
45
|
+
"plugin:approval:store"
|
|
46
|
+
);
|
|
47
|
+
var ApprovalServiceToken = /* @__PURE__ */ Symbol.for(
|
|
48
|
+
"plugin:approval:service"
|
|
49
|
+
);
|
|
50
|
+
var ChallengeServiceToken = /* @__PURE__ */ Symbol.for(
|
|
51
|
+
"plugin:approval:challenge-service"
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// plugins/plugin-approval/src/hooks/approval-check.hook.ts
|
|
55
|
+
import { DynamicPlugin, Plugin, ToolHook } from "@frontmcp/sdk";
|
|
29
56
|
|
|
30
57
|
// plugins/plugin-approval/src/approval/types.ts
|
|
31
58
|
var ApprovalScope = /* @__PURE__ */ ((ApprovalScope2) => {
|
|
@@ -390,269 +417,125 @@ var ChallengeValidationError = class extends ApprovalError {
|
|
|
390
417
|
}
|
|
391
418
|
};
|
|
392
419
|
|
|
393
|
-
// plugins/plugin-approval/src/
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
this.options = {
|
|
405
|
-
storage: options.storage ?? { type: "auto" },
|
|
406
|
-
storageInstance: options.storageInstance,
|
|
407
|
-
namespace: options.namespace ?? "approval",
|
|
408
|
-
cleanupIntervalSeconds: options.cleanupIntervalSeconds ?? 60
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
/**
|
|
412
|
-
* Initialize the storage connection.
|
|
413
|
-
*/
|
|
414
|
-
async initialize() {
|
|
415
|
-
if (this.initialized) {
|
|
420
|
+
// plugins/plugin-approval/src/hooks/approval-check.hook.ts
|
|
421
|
+
var passedApprovalStores = /* @__PURE__ */ new WeakMap();
|
|
422
|
+
var ApprovalCheckPlugin = class extends DynamicPlugin {
|
|
423
|
+
async checkApproval(flowCtx) {
|
|
424
|
+
const { tool, toolContext } = flowCtx.state;
|
|
425
|
+
if (!tool || !toolContext) return;
|
|
426
|
+
const metadata = tool.metadata;
|
|
427
|
+
const approvalConfig = this.resolveApprovalConfig(
|
|
428
|
+
metadata["approval"]
|
|
429
|
+
);
|
|
430
|
+
if (!approvalConfig.required) {
|
|
416
431
|
return;
|
|
417
432
|
}
|
|
418
|
-
if (
|
|
419
|
-
|
|
420
|
-
this.ownedStorage = false;
|
|
421
|
-
} else {
|
|
422
|
-
const rootStorage = await createStorage(this.options.storage);
|
|
423
|
-
this.storage = rootStorage.namespace(this.options.namespace);
|
|
424
|
-
this.ownedStorage = true;
|
|
425
|
-
}
|
|
426
|
-
if (this.options.cleanupIntervalSeconds > 0) {
|
|
427
|
-
this.cleanupInterval = setInterval(() => {
|
|
428
|
-
void this.clearExpiredApprovals();
|
|
429
|
-
}, this.options.cleanupIntervalSeconds * 1e3);
|
|
430
|
-
this.cleanupInterval.unref?.();
|
|
433
|
+
if (approvalConfig.skipApproval) {
|
|
434
|
+
return;
|
|
431
435
|
}
|
|
432
|
-
|
|
436
|
+
const approvalStore = this.get(ApprovalStoreToken);
|
|
437
|
+
const passedStores = passedApprovalStores.get(toolContext) ?? /* @__PURE__ */ new WeakSet();
|
|
438
|
+
if (passedStores.has(approvalStore)) return;
|
|
439
|
+
await this.enforceApproval(flowCtx, tool, toolContext, approvalConfig, approvalStore);
|
|
440
|
+
passedStores.add(approvalStore);
|
|
441
|
+
passedApprovalStores.set(toolContext, passedStores);
|
|
433
442
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
443
|
+
async enforceApproval(flowCtx, tool, toolContext, approvalConfig, approvalStore) {
|
|
444
|
+
const { sessionId, userId } = resolveApprovalIdentity(toolContext.tryGetContext?.());
|
|
445
|
+
const approval = await approvalStore.getApproval(tool.fullName, sessionId, userId);
|
|
446
|
+
if (approval?.state === "denied" /* DENIED */) {
|
|
447
|
+
throw new ApprovalRequiredError({
|
|
448
|
+
toolId: tool.fullName,
|
|
449
|
+
state: "denied",
|
|
450
|
+
message: `Tool "${tool.fullName}" execution denied.`
|
|
451
|
+
});
|
|
437
452
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const parts = [toolId];
|
|
441
|
-
if (sessionId) parts.push(`session:${sessionId}`);
|
|
442
|
-
if (userId) parts.push(`user:${userId}`);
|
|
443
|
-
if (context) parts.push(`ctx:${context.type}:${context.identifier}`);
|
|
444
|
-
return parts.join(":");
|
|
445
|
-
}
|
|
446
|
-
parseRecord(value) {
|
|
447
|
-
if (!value) return void 0;
|
|
448
|
-
try {
|
|
449
|
-
const parsed = JSON.parse(value);
|
|
450
|
-
const result = approvalRecordSchema.safeParse(parsed);
|
|
451
|
-
if (!result.success) {
|
|
452
|
-
return void 0;
|
|
453
|
-
}
|
|
454
|
-
return result.data;
|
|
455
|
-
} catch {
|
|
456
|
-
return void 0;
|
|
453
|
+
if (this.isPreApprovedContext(approvalConfig, this.getCurrentContext(flowCtx))) {
|
|
454
|
+
return;
|
|
457
455
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
}
|
|
462
|
-
async getApproval(toolId, sessionId, userId) {
|
|
463
|
-
this.ensureInitialized();
|
|
464
|
-
const sessionKey = this.buildKey(toolId, sessionId);
|
|
465
|
-
const sessionValue = await this.storage.get(sessionKey);
|
|
466
|
-
const sessionApproval = this.parseRecord(sessionValue);
|
|
467
|
-
if (sessionApproval && !this.isExpired(sessionApproval)) {
|
|
468
|
-
return sessionApproval;
|
|
456
|
+
if (approvalConfig.alwaysPrompt) {
|
|
457
|
+
await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
|
|
458
|
+
return;
|
|
469
459
|
}
|
|
470
|
-
if (
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
const userApproval = this.parseRecord(userValue);
|
|
474
|
-
if (userApproval && !this.isExpired(userApproval)) {
|
|
475
|
-
return userApproval;
|
|
460
|
+
if (approval?.state === "approved" /* APPROVED */) {
|
|
461
|
+
if (!this.isExpired(approval)) {
|
|
462
|
+
return;
|
|
476
463
|
}
|
|
477
464
|
}
|
|
478
|
-
|
|
465
|
+
await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
|
|
479
466
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const pattern = query.toolId ? `${query.toolId}:*` : "*";
|
|
484
|
-
const keys = await this.storage.keys(pattern);
|
|
485
|
-
const values = await this.storage.mget(keys);
|
|
486
|
-
for (const value of values) {
|
|
487
|
-
const approval = this.parseRecord(value);
|
|
488
|
-
if (!approval) continue;
|
|
489
|
-
if (!query.includeExpired && this.isExpired(approval)) {
|
|
490
|
-
continue;
|
|
491
|
-
}
|
|
492
|
-
if (query.toolId && approval.toolId !== query.toolId) continue;
|
|
493
|
-
if (query.toolIds && !query.toolIds.includes(approval.toolId)) continue;
|
|
494
|
-
if (query.scope && approval.scope !== query.scope) continue;
|
|
495
|
-
if (query.scopes && !query.scopes.includes(approval.scope)) continue;
|
|
496
|
-
if (query.state && approval.state !== query.state) continue;
|
|
497
|
-
if (query.states && !query.states.includes(approval.state)) continue;
|
|
498
|
-
if (query.sessionId && approval.sessionId !== query.sessionId) continue;
|
|
499
|
-
if (query.userId && approval.userId !== query.userId) continue;
|
|
500
|
-
if (query.context) {
|
|
501
|
-
if (!approval.context || approval.context.type !== query.context.type || approval.context.identifier !== query.context.identifier) {
|
|
502
|
-
continue;
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
results.push(approval);
|
|
467
|
+
resolveApprovalConfig(config) {
|
|
468
|
+
if (config === true) {
|
|
469
|
+
return { required: true, defaultScope: "session" /* SESSION */ };
|
|
506
470
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
const record = {
|
|
515
|
-
toolId: options.toolId,
|
|
516
|
-
state: "approved" /* APPROVED */,
|
|
517
|
-
scope: options.scope,
|
|
518
|
-
grantedAt: now,
|
|
519
|
-
expiresAt,
|
|
520
|
-
ttlMs: options.ttlMs,
|
|
521
|
-
sessionId: options.sessionId,
|
|
522
|
-
userId: options.userId,
|
|
523
|
-
context: options.context,
|
|
524
|
-
grantedBy,
|
|
525
|
-
reason: options.reason,
|
|
526
|
-
metadata: options.metadata
|
|
471
|
+
if (config === false || config === void 0) {
|
|
472
|
+
return { required: false };
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
...config,
|
|
476
|
+
required: config.required ?? true,
|
|
477
|
+
defaultScope: config.defaultScope ?? "session" /* SESSION */
|
|
527
478
|
};
|
|
528
|
-
const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
|
|
529
|
-
const ttlSeconds = options.ttlMs ? Math.ceil(options.ttlMs / 1e3) : void 0;
|
|
530
|
-
await this.storage.set(key, JSON.stringify(record), { ttlSeconds });
|
|
531
|
-
return record;
|
|
532
479
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
const exists = await this.storage.exists(key);
|
|
537
|
-
if (exists) {
|
|
538
|
-
await this.storage.delete(key);
|
|
539
|
-
return true;
|
|
540
|
-
}
|
|
541
|
-
return false;
|
|
480
|
+
isExpired(approval) {
|
|
481
|
+
if (!approval.expiresAt) return false;
|
|
482
|
+
return Date.now() > approval.expiresAt;
|
|
542
483
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
if (sessionApproval && sessionApproval.state === "approved" /* APPROVED */ && !this.isExpired(sessionApproval)) {
|
|
557
|
-
return true;
|
|
558
|
-
}
|
|
559
|
-
if (userId) {
|
|
560
|
-
const userKey = this.buildKey(toolId, void 0, userId);
|
|
561
|
-
const userValue = await this.storage.get(userKey);
|
|
562
|
-
const userApproval = this.parseRecord(userValue);
|
|
563
|
-
if (userApproval && userApproval.state === "approved" /* APPROVED */ && !this.isExpired(userApproval)) {
|
|
564
|
-
return true;
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
return false;
|
|
484
|
+
/**
|
|
485
|
+
* The context this call is running in, as established by the SESSION (GHSA-r848-p7wf-96rc).
|
|
486
|
+
*
|
|
487
|
+
* Deliberately ignores `toolContext.input.context`. The only consumer of this value is
|
|
488
|
+
* `isPreApprovedContext`, which skips the approval prompt outright, so reading it from the
|
|
489
|
+
* arguments of the call being gated let a caller name its own pre-approved context and walk
|
|
490
|
+
* straight past the gate. Only `authInfo.extra.approvalContext`, which the server sets
|
|
491
|
+
* while authenticating, can say what context a call belongs to.
|
|
492
|
+
*/
|
|
493
|
+
getCurrentContext(flowCtx) {
|
|
494
|
+
const ctx = flowCtx.state.toolContext?.tryGetContext?.();
|
|
495
|
+
const sessionContext = ctx?.authInfo?.extra?.["approvalContext"];
|
|
496
|
+
return this.isApprovalContext(sessionContext) ? sessionContext : void 0;
|
|
568
497
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
return 0;
|
|
498
|
+
isApprovalContext(value) {
|
|
499
|
+
return typeof value === "object" && value !== null && "type" in value && "identifier" in value && typeof value.type === "string" && typeof value.identifier === "string";
|
|
500
|
+
}
|
|
501
|
+
isPreApprovedContext(config, currentContext) {
|
|
502
|
+
if (!currentContext || !config.preApprovedContexts?.length) {
|
|
503
|
+
return false;
|
|
576
504
|
}
|
|
577
|
-
return
|
|
505
|
+
return config.preApprovedContexts.some(
|
|
506
|
+
(preApproved) => preApproved.type === currentContext.type && preApproved.identifier === currentContext.identifier
|
|
507
|
+
);
|
|
578
508
|
}
|
|
579
|
-
async
|
|
580
|
-
|
|
581
|
-
const
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
}
|
|
594
|
-
return 0;
|
|
595
|
-
}
|
|
596
|
-
async getStats() {
|
|
597
|
-
this.ensureInitialized();
|
|
598
|
-
const byScope = {
|
|
599
|
-
["session" /* SESSION */]: 0,
|
|
600
|
-
["user" /* USER */]: 0,
|
|
601
|
-
["time_limited" /* TIME_LIMITED */]: 0,
|
|
602
|
-
["tool_specific" /* TOOL_SPECIFIC */]: 0,
|
|
603
|
-
["context_specific" /* CONTEXT_SPECIFIC */]: 0
|
|
604
|
-
};
|
|
605
|
-
const byState = {
|
|
606
|
-
["pending" /* PENDING */]: 0,
|
|
607
|
-
["approved" /* APPROVED */]: 0,
|
|
608
|
-
["denied" /* DENIED */]: 0,
|
|
609
|
-
["expired" /* EXPIRED */]: 0
|
|
610
|
-
};
|
|
611
|
-
const keys = await this.storage.keys("*");
|
|
612
|
-
const values = await this.storage.mget(keys);
|
|
613
|
-
let total = 0;
|
|
614
|
-
for (const value of values) {
|
|
615
|
-
const approval = this.parseRecord(value);
|
|
616
|
-
if (approval) {
|
|
617
|
-
total++;
|
|
618
|
-
byScope[approval.scope]++;
|
|
619
|
-
byState[approval.state]++;
|
|
509
|
+
async handleApprovalRequired(flowCtx, config, existingApproval) {
|
|
510
|
+
const { tool } = flowCtx.state;
|
|
511
|
+
const message = config.approvalMessage ?? `Tool "${tool?.fullName}" requires approval to execute. Allow?`;
|
|
512
|
+
const isExpiredApproval = existingApproval ? this.isExpired(existingApproval) : false;
|
|
513
|
+
throw new ApprovalRequiredError({
|
|
514
|
+
toolId: tool?.fullName ?? "unknown",
|
|
515
|
+
state: isExpiredApproval ? "expired" : "pending",
|
|
516
|
+
message,
|
|
517
|
+
approvalOptions: {
|
|
518
|
+
allowedScopes: config.allowedScopes,
|
|
519
|
+
defaultScope: config.defaultScope,
|
|
520
|
+
maxTtlMs: config.maxTtlMs,
|
|
521
|
+
category: config.category,
|
|
522
|
+
riskLevel: config.riskLevel
|
|
620
523
|
}
|
|
621
|
-
}
|
|
622
|
-
return {
|
|
623
|
-
totalApprovals: total,
|
|
624
|
-
byScope,
|
|
625
|
-
byState
|
|
626
|
-
};
|
|
627
|
-
}
|
|
628
|
-
async close() {
|
|
629
|
-
if (this.cleanupInterval) {
|
|
630
|
-
clearInterval(this.cleanupInterval);
|
|
631
|
-
this.cleanupInterval = void 0;
|
|
632
|
-
}
|
|
633
|
-
if (this.ownedStorage && this.storage) {
|
|
634
|
-
await this.storage.root.disconnect();
|
|
635
|
-
}
|
|
636
|
-
this.initialized = false;
|
|
524
|
+
});
|
|
637
525
|
}
|
|
638
526
|
};
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
527
|
+
__decorateClass([
|
|
528
|
+
ToolHook.Will("execute", { priority: 100 })
|
|
529
|
+
], ApprovalCheckPlugin.prototype, "checkApproval", 1);
|
|
530
|
+
ApprovalCheckPlugin = __decorateClass([
|
|
531
|
+
Plugin({
|
|
532
|
+
name: "approval:check",
|
|
533
|
+
description: "Checks tool approval state before execution"
|
|
644
534
|
})
|
|
645
|
-
],
|
|
646
|
-
function createApprovalMemoryStore(options = {}) {
|
|
647
|
-
const memoryStorage = createMemoryStorage();
|
|
648
|
-
return new ApprovalStorageStore({
|
|
649
|
-
...options,
|
|
650
|
-
storageInstance: memoryStorage
|
|
651
|
-
});
|
|
652
|
-
}
|
|
535
|
+
], ApprovalCheckPlugin);
|
|
653
536
|
|
|
654
537
|
// plugins/plugin-approval/src/services/approval.service.ts
|
|
655
|
-
import { Provider
|
|
538
|
+
import { Provider, ProviderScope } from "@frontmcp/sdk";
|
|
656
539
|
var ApprovalService = class {
|
|
657
540
|
constructor(store, sessionId, userId) {
|
|
658
541
|
this.store = store;
|
|
@@ -794,10 +677,10 @@ var ApprovalService = class {
|
|
|
794
677
|
}
|
|
795
678
|
};
|
|
796
679
|
ApprovalService = __decorateClass([
|
|
797
|
-
|
|
680
|
+
Provider({
|
|
798
681
|
name: "provider:approval:service",
|
|
799
682
|
description: "Service for managing tool approvals",
|
|
800
|
-
scope:
|
|
683
|
+
scope: ProviderScope.CONTEXT
|
|
801
684
|
})
|
|
802
685
|
], ApprovalService);
|
|
803
686
|
function createApprovalService(store, sessionId, userId) {
|
|
@@ -805,12 +688,12 @@ function createApprovalService(store, sessionId, userId) {
|
|
|
805
688
|
}
|
|
806
689
|
|
|
807
690
|
// plugins/plugin-approval/src/services/challenge.service.ts
|
|
808
|
-
import { Provider as
|
|
691
|
+
import { Provider as Provider2, ProviderScope as ProviderScope2 } from "@frontmcp/sdk";
|
|
809
692
|
import {
|
|
810
693
|
generatePkcePair,
|
|
811
694
|
generateCodeChallenge,
|
|
812
|
-
createStorage
|
|
813
|
-
createMemoryStorage
|
|
695
|
+
createStorage,
|
|
696
|
+
createMemoryStorage
|
|
814
697
|
} from "@frontmcp/utils";
|
|
815
698
|
var ChallengeService = class {
|
|
816
699
|
storage;
|
|
@@ -834,7 +717,7 @@ var ChallengeService = class {
|
|
|
834
717
|
this.storage = this.options.storageInstance.namespace(this.options.namespace);
|
|
835
718
|
this.ownedStorage = false;
|
|
836
719
|
} else {
|
|
837
|
-
const rootStorage = await
|
|
720
|
+
const rootStorage = await createStorage(this.options.storage);
|
|
838
721
|
this.storage = rootStorage.namespace(this.options.namespace);
|
|
839
722
|
this.ownedStorage = true;
|
|
840
723
|
}
|
|
@@ -944,10 +827,10 @@ var ChallengeService = class {
|
|
|
944
827
|
}
|
|
945
828
|
};
|
|
946
829
|
ChallengeService = __decorateClass([
|
|
947
|
-
|
|
830
|
+
Provider2({
|
|
948
831
|
name: "provider:approval:challenge-service",
|
|
949
832
|
description: "PKCE challenge service for webhook approval flows",
|
|
950
|
-
scope:
|
|
833
|
+
scope: ProviderScope2.GLOBAL
|
|
951
834
|
})
|
|
952
835
|
], ChallengeService);
|
|
953
836
|
function generatePkcePairFromVerifier(codeVerifier) {
|
|
@@ -957,141 +840,279 @@ function generatePkcePairFromVerifier(codeVerifier) {
|
|
|
957
840
|
};
|
|
958
841
|
}
|
|
959
842
|
function createMemoryChallengeService(options = {}) {
|
|
960
|
-
const memoryStorage =
|
|
843
|
+
const memoryStorage = createMemoryStorage();
|
|
961
844
|
return new ChallengeService({
|
|
962
845
|
...options,
|
|
963
846
|
storageInstance: memoryStorage
|
|
964
847
|
});
|
|
965
848
|
}
|
|
966
849
|
|
|
967
|
-
// plugins/plugin-approval/src/approval.
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
)
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
const sessionId = ctx?.sessionId ?? "unknown";
|
|
996
|
-
const userId = this.getStringExtra(ctx?.authInfo?.extra, "userId") ?? this.getStringExtra(ctx?.authInfo?.extra, "sub") ?? ctx?.authInfo?.clientId;
|
|
997
|
-
const currentContext = this.getCurrentContext(flowCtx);
|
|
998
|
-
if (this.isPreApprovedContext(approvalConfig, currentContext)) {
|
|
999
|
-
return;
|
|
1000
|
-
}
|
|
1001
|
-
const approvalStore = this.get(ApprovalStoreToken);
|
|
1002
|
-
const approval = await approvalStore.getApproval(tool.fullName, sessionId, userId);
|
|
1003
|
-
if (approvalConfig.alwaysPrompt) {
|
|
1004
|
-
await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
|
|
850
|
+
// plugins/plugin-approval/src/stores/approval-storage.store.ts
|
|
851
|
+
import { Provider as Provider3, ProviderScope as ProviderScope3 } from "@frontmcp/sdk";
|
|
852
|
+
import {
|
|
853
|
+
createMemoryStorage as createMemoryStorage2,
|
|
854
|
+
createStorage as createStorage2
|
|
855
|
+
} from "@frontmcp/utils";
|
|
856
|
+
function escapePattern(str) {
|
|
857
|
+
return str.replace(/[*?[\]\\]/g, "\\$&");
|
|
858
|
+
}
|
|
859
|
+
var ApprovalStorageStore = class {
|
|
860
|
+
storage;
|
|
861
|
+
options;
|
|
862
|
+
cleanupInterval;
|
|
863
|
+
initialized = false;
|
|
864
|
+
ownedStorage = false;
|
|
865
|
+
constructor(options = {}) {
|
|
866
|
+
this.options = {
|
|
867
|
+
storage: options.storage ?? { type: "auto" },
|
|
868
|
+
storageInstance: options.storageInstance,
|
|
869
|
+
namespace: options.namespace ?? "approval",
|
|
870
|
+
cleanupIntervalSeconds: options.cleanupIntervalSeconds ?? 60
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Initialize the storage connection.
|
|
875
|
+
*/
|
|
876
|
+
async initialize() {
|
|
877
|
+
if (this.initialized) {
|
|
1005
878
|
return;
|
|
1006
879
|
}
|
|
1007
|
-
if (
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
880
|
+
if (this.options.storageInstance) {
|
|
881
|
+
this.storage = this.options.storageInstance.namespace(this.options.namespace);
|
|
882
|
+
this.ownedStorage = false;
|
|
883
|
+
} else {
|
|
884
|
+
const rootStorage = await createStorage2(this.options.storage);
|
|
885
|
+
this.storage = rootStorage.namespace(this.options.namespace);
|
|
886
|
+
this.ownedStorage = true;
|
|
1011
887
|
}
|
|
1012
|
-
if (
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
});
|
|
888
|
+
if (this.options.cleanupIntervalSeconds > 0) {
|
|
889
|
+
this.cleanupInterval = setInterval(() => {
|
|
890
|
+
void this.clearExpiredApprovals();
|
|
891
|
+
}, this.options.cleanupIntervalSeconds * 1e3);
|
|
892
|
+
this.cleanupInterval.unref?.();
|
|
1018
893
|
}
|
|
1019
|
-
|
|
894
|
+
this.initialized = true;
|
|
1020
895
|
}
|
|
1021
|
-
|
|
1022
|
-
if (
|
|
1023
|
-
|
|
896
|
+
ensureInitialized() {
|
|
897
|
+
if (!this.initialized) {
|
|
898
|
+
throw new Error("ApprovalStorageStore not initialized. Call initialize() first.");
|
|
1024
899
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
900
|
+
}
|
|
901
|
+
buildKey(toolId, sessionId, userId, context) {
|
|
902
|
+
const parts = [toolId];
|
|
903
|
+
if (sessionId) parts.push(`session:${sessionId}`);
|
|
904
|
+
if (userId) parts.push(`user:${userId}`);
|
|
905
|
+
if (context) parts.push(`ctx:${context.type}:${context.identifier}`);
|
|
906
|
+
return parts.join(":");
|
|
907
|
+
}
|
|
908
|
+
parseRecord(value) {
|
|
909
|
+
if (!value) return void 0;
|
|
910
|
+
try {
|
|
911
|
+
const parsed = JSON.parse(value);
|
|
912
|
+
const result = approvalRecordSchema.safeParse(parsed);
|
|
913
|
+
if (!result.success) {
|
|
914
|
+
return void 0;
|
|
915
|
+
}
|
|
916
|
+
return result.data;
|
|
917
|
+
} catch {
|
|
918
|
+
return void 0;
|
|
1027
919
|
}
|
|
1028
|
-
return {
|
|
1029
|
-
...config,
|
|
1030
|
-
required: config.required ?? true,
|
|
1031
|
-
defaultScope: config.defaultScope ?? "session" /* SESSION */
|
|
1032
|
-
};
|
|
1033
920
|
}
|
|
1034
921
|
isExpired(approval) {
|
|
1035
|
-
|
|
1036
|
-
return Date.now() > approval.expiresAt;
|
|
922
|
+
return approval.expiresAt !== void 0 && Date.now() > approval.expiresAt;
|
|
1037
923
|
}
|
|
1038
924
|
/**
|
|
1039
|
-
* The
|
|
1040
|
-
*
|
|
1041
|
-
* Deliberately ignores `toolContext.input.context`. The only consumer of this value is
|
|
1042
|
-
* `isPreApprovedContext`, which skips the approval prompt outright, so reading it from the
|
|
1043
|
-
* arguments of the call being gated let a caller name its own pre-approved context and walk
|
|
1044
|
-
* straight past the gate. Only `authInfo.extra.approvalContext`, which the server sets
|
|
1045
|
-
* while authenticating, can say what context a call belongs to.
|
|
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.
|
|
1046
927
|
*/
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
const
|
|
1050
|
-
|
|
928
|
+
async getApproval(toolId, sessionId, userId) {
|
|
929
|
+
this.ensureInitialized();
|
|
930
|
+
const keys = [this.buildKey(toolId, sessionId)];
|
|
931
|
+
if (userId) {
|
|
932
|
+
keys.push(this.buildKey(toolId, void 0, userId));
|
|
933
|
+
}
|
|
934
|
+
const activeRecords = [];
|
|
935
|
+
for (const key of keys) {
|
|
936
|
+
const record = this.parseRecord(await this.storage.get(key));
|
|
937
|
+
if (record && !this.isExpired(record)) {
|
|
938
|
+
activeRecords.push(record);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
return activeRecords.find((record) => record.state === "denied" /* DENIED */) ?? activeRecords[0];
|
|
1051
942
|
}
|
|
1052
|
-
|
|
1053
|
-
|
|
943
|
+
async queryApprovals(query) {
|
|
944
|
+
this.ensureInitialized();
|
|
945
|
+
const results = [];
|
|
946
|
+
const pattern = query.toolId ? `${query.toolId}:*` : "*";
|
|
947
|
+
const keys = await this.storage.keys(pattern);
|
|
948
|
+
const values = await this.storage.mget(keys);
|
|
949
|
+
for (const value of values) {
|
|
950
|
+
const approval = this.parseRecord(value);
|
|
951
|
+
if (!approval) continue;
|
|
952
|
+
if (!query.includeExpired && this.isExpired(approval)) {
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
if (query.toolId && approval.toolId !== query.toolId) continue;
|
|
956
|
+
if (query.toolIds && !query.toolIds.includes(approval.toolId)) continue;
|
|
957
|
+
if (query.scope && approval.scope !== query.scope) continue;
|
|
958
|
+
if (query.scopes && !query.scopes.includes(approval.scope)) continue;
|
|
959
|
+
if (query.state && approval.state !== query.state) continue;
|
|
960
|
+
if (query.states && !query.states.includes(approval.state)) continue;
|
|
961
|
+
if (query.sessionId && approval.sessionId !== query.sessionId) continue;
|
|
962
|
+
if (query.userId && approval.userId !== query.userId) continue;
|
|
963
|
+
if (query.context) {
|
|
964
|
+
if (!approval.context || approval.context.type !== query.context.type || approval.context.identifier !== query.context.identifier) {
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
results.push(approval);
|
|
969
|
+
}
|
|
970
|
+
return results;
|
|
1054
971
|
}
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
const
|
|
1058
|
-
|
|
972
|
+
async grantApproval(options) {
|
|
973
|
+
this.ensureInitialized();
|
|
974
|
+
const now = Date.now();
|
|
975
|
+
const expiresAt = options.ttlMs ? now + options.ttlMs : void 0;
|
|
976
|
+
const grantedBy = normalizeGrantor(options.grantedBy);
|
|
977
|
+
const record = {
|
|
978
|
+
toolId: options.toolId,
|
|
979
|
+
state: "approved" /* APPROVED */,
|
|
980
|
+
scope: options.scope,
|
|
981
|
+
grantedAt: now,
|
|
982
|
+
expiresAt,
|
|
983
|
+
ttlMs: options.ttlMs,
|
|
984
|
+
sessionId: options.sessionId,
|
|
985
|
+
userId: options.userId,
|
|
986
|
+
context: options.context,
|
|
987
|
+
grantedBy,
|
|
988
|
+
reason: options.reason,
|
|
989
|
+
metadata: options.metadata
|
|
990
|
+
};
|
|
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;
|
|
993
|
+
await this.storage.set(key, JSON.stringify(record), { ttlSeconds });
|
|
994
|
+
return record;
|
|
1059
995
|
}
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
996
|
+
async revokeApproval(options) {
|
|
997
|
+
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;
|
|
1063
1003
|
}
|
|
1064
|
-
return
|
|
1065
|
-
(preApproved) => preApproved.type === currentContext.type && preApproved.identifier === currentContext.identifier
|
|
1066
|
-
);
|
|
1004
|
+
return false;
|
|
1067
1005
|
}
|
|
1068
|
-
async
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
state
|
|
1075
|
-
|
|
1076
|
-
approvalOptions: {
|
|
1077
|
-
allowedScopes: config.allowedScopes,
|
|
1078
|
-
defaultScope: config.defaultScope,
|
|
1079
|
-
maxTtlMs: config.maxTtlMs,
|
|
1080
|
-
category: config.category,
|
|
1081
|
-
riskLevel: config.riskLevel
|
|
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;
|
|
1082
1014
|
}
|
|
1083
|
-
}
|
|
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;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return false;
|
|
1031
|
+
}
|
|
1032
|
+
async clearSessionApprovals(sessionId) {
|
|
1033
|
+
this.ensureInitialized();
|
|
1034
|
+
const escapedSessionId = escapePattern(sessionId);
|
|
1035
|
+
const pattern = `*:session:${escapedSessionId}*`;
|
|
1036
|
+
const keys = await this.storage.keys(pattern);
|
|
1037
|
+
if (keys.length === 0) {
|
|
1038
|
+
return 0;
|
|
1039
|
+
}
|
|
1040
|
+
return await this.storage.mdelete(keys);
|
|
1041
|
+
}
|
|
1042
|
+
async clearExpiredApprovals() {
|
|
1043
|
+
this.ensureInitialized();
|
|
1044
|
+
const now = Date.now();
|
|
1045
|
+
const keys = await this.storage.keys("*");
|
|
1046
|
+
const values = await this.storage.mget(keys);
|
|
1047
|
+
const keysToDelete = [];
|
|
1048
|
+
for (let i = 0; i < keys.length; i++) {
|
|
1049
|
+
const approval = this.parseRecord(values[i]);
|
|
1050
|
+
if (approval && approval.expiresAt && approval.expiresAt <= now) {
|
|
1051
|
+
keysToDelete.push(keys[i]);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
if (keysToDelete.length > 0) {
|
|
1055
|
+
return await this.storage.mdelete(keysToDelete);
|
|
1056
|
+
}
|
|
1057
|
+
return 0;
|
|
1058
|
+
}
|
|
1059
|
+
async getStats() {
|
|
1060
|
+
this.ensureInitialized();
|
|
1061
|
+
const byScope = {
|
|
1062
|
+
["session" /* SESSION */]: 0,
|
|
1063
|
+
["user" /* USER */]: 0,
|
|
1064
|
+
["time_limited" /* TIME_LIMITED */]: 0,
|
|
1065
|
+
["tool_specific" /* TOOL_SPECIFIC */]: 0,
|
|
1066
|
+
["context_specific" /* CONTEXT_SPECIFIC */]: 0
|
|
1067
|
+
};
|
|
1068
|
+
const byState = {
|
|
1069
|
+
["pending" /* PENDING */]: 0,
|
|
1070
|
+
["approved" /* APPROVED */]: 0,
|
|
1071
|
+
["denied" /* DENIED */]: 0,
|
|
1072
|
+
["expired" /* EXPIRED */]: 0
|
|
1073
|
+
};
|
|
1074
|
+
const keys = await this.storage.keys("*");
|
|
1075
|
+
const values = await this.storage.mget(keys);
|
|
1076
|
+
let total = 0;
|
|
1077
|
+
for (const value of values) {
|
|
1078
|
+
const approval = this.parseRecord(value);
|
|
1079
|
+
if (approval) {
|
|
1080
|
+
total++;
|
|
1081
|
+
byScope[approval.scope]++;
|
|
1082
|
+
byState[approval.state]++;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return {
|
|
1086
|
+
totalApprovals: total,
|
|
1087
|
+
byScope,
|
|
1088
|
+
byState
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
async close() {
|
|
1092
|
+
if (this.cleanupInterval) {
|
|
1093
|
+
clearInterval(this.cleanupInterval);
|
|
1094
|
+
this.cleanupInterval = void 0;
|
|
1095
|
+
}
|
|
1096
|
+
if (this.ownedStorage && this.storage) {
|
|
1097
|
+
await this.storage.root.disconnect();
|
|
1098
|
+
}
|
|
1099
|
+
this.initialized = false;
|
|
1084
1100
|
}
|
|
1085
1101
|
};
|
|
1086
|
-
__decorateClass([
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
name: "approval:check",
|
|
1092
|
-
description: "Checks tool approval state before execution"
|
|
1102
|
+
ApprovalStorageStore = __decorateClass([
|
|
1103
|
+
Provider3({
|
|
1104
|
+
name: "provider:approval:store:storage",
|
|
1105
|
+
description: "Storage-backed approval store (supports Memory, Redis, Vercel KV, Upstash)",
|
|
1106
|
+
scope: ProviderScope3.GLOBAL
|
|
1093
1107
|
})
|
|
1094
|
-
],
|
|
1108
|
+
], ApprovalStorageStore);
|
|
1109
|
+
function createApprovalMemoryStore(options = {}) {
|
|
1110
|
+
const memoryStorage = createMemoryStorage2();
|
|
1111
|
+
return new ApprovalStorageStore({
|
|
1112
|
+
...options,
|
|
1113
|
+
storageInstance: memoryStorage
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1095
1116
|
|
|
1096
1117
|
// plugins/plugin-approval/src/approval.plugin.ts
|
|
1097
1118
|
var ApprovalPlugin = class extends DynamicPlugin2 {
|
|
@@ -1103,12 +1124,6 @@ var ApprovalPlugin = class extends DynamicPlugin2 {
|
|
|
1103
1124
|
...options
|
|
1104
1125
|
};
|
|
1105
1126
|
}
|
|
1106
|
-
/**
|
|
1107
|
-
* Get plugin metadata including nested plugins.
|
|
1108
|
-
*/
|
|
1109
|
-
static getPluginMetadata(_options) {
|
|
1110
|
-
return { plugins: [ApprovalCheckPlugin] };
|
|
1111
|
-
}
|
|
1112
1127
|
};
|
|
1113
1128
|
__publicField(ApprovalPlugin, "defaultOptions", {
|
|
1114
1129
|
namespace: "approval",
|
|
@@ -1164,8 +1179,8 @@ __publicField(ApprovalPlugin, "dynamicProviders", (options) => {
|
|
|
1164
1179
|
scope: ProviderScope4.CONTEXT,
|
|
1165
1180
|
inject: () => [ApprovalStoreToken, FRONTMCP_CONTEXT],
|
|
1166
1181
|
useFactory: (store, ctx) => {
|
|
1167
|
-
const
|
|
1168
|
-
return createApprovalService(store,
|
|
1182
|
+
const { sessionId, userId } = resolveApprovalIdentity(ctx);
|
|
1183
|
+
return createApprovalService(store, sessionId, userId);
|
|
1169
1184
|
}
|
|
1170
1185
|
});
|
|
1171
1186
|
return providers;
|
|
@@ -1174,6 +1189,7 @@ ApprovalPlugin = __decorateClass([
|
|
|
1174
1189
|
Plugin2({
|
|
1175
1190
|
name: "approval",
|
|
1176
1191
|
description: "Tool authorization workflow with PKCE webhook security",
|
|
1192
|
+
plugins: [ApprovalCheckPlugin],
|
|
1177
1193
|
contextExtensions: [
|
|
1178
1194
|
{
|
|
1179
1195
|
property: "approval",
|