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