@agent-native/dispatch 0.14.2 → 0.14.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -122
- package/dist/actions/approve-dispatch-change.d.ts +10 -10
- package/dist/actions/list_apps.d.ts +1 -0
- package/dist/actions/list_apps.js +16 -8
- package/dist/actions/list_apps.js.map +1 -1
- package/dist/actions/provider-api-register.js +64 -2
- package/dist/actions/provider-api-register.js.map +1 -1
- package/dist/actions/reject-dispatch-change.d.ts +10 -10
- package/dist/actions/set-mcp-app-access.js +1 -4
- package/dist/actions/set-mcp-app-access.js.map +1 -1
- package/dist/components/action-query-error.d.ts +8 -0
- package/dist/components/action-query-error.d.ts.map +1 -0
- package/dist/components/action-query-error.js +10 -0
- package/dist/components/action-query-error.js.map +1 -0
- package/dist/components/app-keys-popover.d.ts +4 -0
- package/dist/components/app-keys-popover.d.ts.map +1 -1
- package/dist/components/app-keys-popover.js +26 -14
- package/dist/components/app-keys-popover.js.map +1 -1
- package/dist/components/dispatch-control-plane.d.ts.map +1 -1
- package/dist/components/dispatch-control-plane.js +31 -27
- package/dist/components/dispatch-control-plane.js.map +1 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/components/index.js.map +1 -1
- package/dist/components/layout/Layout.d.ts +1 -0
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +19 -21
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/ui/sheet.js +1 -1
- package/dist/components/ui/sheet.js.map +1 -1
- package/dist/components/ui/toast.js +1 -1
- package/dist/components/ui/toast.js.map +1 -1
- package/dist/components/workspace-app-card.d.ts.map +1 -1
- package/dist/components/workspace-app-card.js +8 -5
- package/dist/components/workspace-app-card.js.map +1 -1
- package/dist/components/workspace-resource-effective-stack.d.ts.map +1 -1
- package/dist/components/workspace-resource-effective-stack.js +6 -1
- package/dist/components/workspace-resource-effective-stack.js.map +1 -1
- package/dist/components/workspace-resource-impact-preview.d.ts.map +1 -1
- package/dist/components/workspace-resource-impact-preview.js +6 -1
- package/dist/components/workspace-resource-impact-preview.js.map +1 -1
- package/dist/routes/pages/$appId.d.ts.map +1 -1
- package/dist/routes/pages/$appId.js +6 -1
- package/dist/routes/pages/$appId.js.map +1 -1
- package/dist/routes/pages/agents.d.ts.map +1 -1
- package/dist/routes/pages/agents.js +5 -8
- package/dist/routes/pages/agents.js.map +1 -1
- package/dist/routes/pages/approval.d.ts.map +1 -1
- package/dist/routes/pages/approval.js +6 -1
- package/dist/routes/pages/approval.js.map +1 -1
- package/dist/routes/pages/approvals.d.ts.map +1 -1
- package/dist/routes/pages/approvals.js +8 -5
- package/dist/routes/pages/approvals.js.map +1 -1
- package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
- package/dist/routes/pages/apps.$appId.js +4 -2
- package/dist/routes/pages/apps.$appId.js.map +1 -1
- package/dist/routes/pages/apps.d.ts.map +1 -1
- package/dist/routes/pages/apps.js +9 -3
- package/dist/routes/pages/apps.js.map +1 -1
- package/dist/routes/pages/audit.d.ts.map +1 -1
- package/dist/routes/pages/audit.js +4 -2
- package/dist/routes/pages/audit.js.map +1 -1
- package/dist/routes/pages/chat.d.ts.map +1 -1
- package/dist/routes/pages/chat.js +16 -2
- package/dist/routes/pages/chat.js.map +1 -1
- package/dist/routes/pages/destinations.d.ts.map +1 -1
- package/dist/routes/pages/destinations.js +5 -3
- package/dist/routes/pages/destinations.js.map +1 -1
- package/dist/routes/pages/identities.d.ts.map +1 -1
- package/dist/routes/pages/identities.js +5 -3
- package/dist/routes/pages/identities.js.map +1 -1
- package/dist/routes/pages/integrations.d.ts.map +1 -1
- package/dist/routes/pages/integrations.js +9 -3
- package/dist/routes/pages/integrations.js.map +1 -1
- package/dist/routes/pages/thread-debug.d.ts.map +1 -1
- package/dist/routes/pages/thread-debug.js +7 -6
- package/dist/routes/pages/thread-debug.js.map +1 -1
- package/dist/routes/pages/vault.d.ts.map +1 -1
- package/dist/routes/pages/vault.js +23 -7
- package/dist/routes/pages/vault.js.map +1 -1
- package/dist/routes/pages/workspace.d.ts.map +1 -1
- package/dist/routes/pages/workspace.js +9 -3
- package/dist/routes/pages/workspace.js.map +1 -1
- package/dist/server/lib/dispatch-store.d.ts +24 -21
- package/dist/server/lib/dispatch-store.d.ts.map +1 -1
- package/dist/server/lib/dispatch-store.js +59 -23
- package/dist/server/lib/dispatch-store.js.map +1 -1
- package/dist/server/lib/mcp-access-store.d.ts.map +1 -1
- package/dist/server/lib/mcp-access-store.js +30 -2
- package/dist/server/lib/mcp-access-store.js.map +1 -1
- package/dist/server/lib/vault-store.d.ts.map +1 -1
- package/dist/server/lib/vault-store.js +102 -37
- package/dist/server/lib/vault-store.js.map +1 -1
- package/dist/server/plugins/agent-chat.d.ts +1 -1
- package/package.json +2 -3
- package/src/actions/list_apps.spec.ts +52 -0
- package/src/actions/list_apps.ts +16 -8
- package/src/actions/provider-api-register.spec.ts +193 -0
- package/src/actions/provider-api-register.ts +66 -1
- package/src/actions/set-mcp-app-access.spec.ts +73 -0
- package/src/actions/set-mcp-app-access.ts +1 -5
- package/src/components/action-query-error.spec.tsx +56 -0
- package/src/components/action-query-error.tsx +32 -0
- package/src/components/app-keys-popover.spec.tsx +85 -0
- package/src/components/app-keys-popover.tsx +76 -50
- package/src/components/dispatch-control-plane.spec.tsx +106 -0
- package/src/components/dispatch-control-plane.tsx +71 -115
- package/src/components/index.ts +1 -0
- package/src/components/layout/Layout.spec.tsx +42 -2
- package/src/components/layout/Layout.tsx +22 -27
- package/src/components/ui/sheet.tsx +1 -1
- package/src/components/ui/toast.tsx +1 -1
- package/src/components/workspace-app-card.spec.tsx +80 -0
- package/src/components/workspace-app-card.tsx +36 -25
- package/src/components/workspace-resource-effective-stack.tsx +13 -1
- package/src/components/workspace-resource-impact-preview.tsx +12 -1
- package/src/routes/pages/$appId.tsx +17 -1
- package/src/routes/pages/agents.tsx +24 -11
- package/src/routes/pages/approval.tsx +16 -4
- package/src/routes/pages/approvals.tsx +108 -89
- package/src/routes/pages/apps.$appId.tsx +9 -2
- package/src/routes/pages/apps.tsx +20 -7
- package/src/routes/pages/audit.tsx +29 -20
- package/src/routes/pages/chat.spec.tsx +94 -0
- package/src/routes/pages/chat.tsx +30 -10
- package/src/routes/pages/destinations.tsx +75 -62
- package/src/routes/pages/identities.tsx +74 -63
- package/src/routes/pages/integrations.tsx +17 -10
- package/src/routes/pages/thread-debug.tsx +18 -12
- package/src/routes/pages/vault.tsx +59 -26
- package/src/routes/pages/workspace.tsx +69 -55
- package/src/server/lib/approval-fencing.spec.ts +420 -0
- package/src/server/lib/dispatch-store.ts +94 -21
- package/src/server/lib/mcp-access-store.spec.ts +116 -4
- package/src/server/lib/mcp-access-store.ts +37 -2
- package/src/server/lib/mcp-gateway.spec.ts +79 -3
- package/src/server/lib/vault-store.ts +151 -45
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
|
|
3
|
-
import { and, desc, eq, isNull, or } from "@agent-native/core/db/schema";
|
|
3
|
+
import { and, desc, eq, isNull, or, sql } from "@agent-native/core/db/schema";
|
|
4
4
|
import {
|
|
5
5
|
getRequestUserEmail,
|
|
6
6
|
getRequestOrgId,
|
|
@@ -11,6 +11,7 @@ import { getDb, schema } from "../../db/index.js";
|
|
|
11
11
|
|
|
12
12
|
export const SHARED_DISPATCH_OWNER = "dispatch@shared";
|
|
13
13
|
const APPROVAL_POLICY_KEY = "dispatch-approval-policy";
|
|
14
|
+
const APPROVAL_APPLY_LEASE_MS = 5 * 60 * 1000;
|
|
14
15
|
|
|
15
16
|
// ─── /link rate limiting ──────────────────────────────────────────────────
|
|
16
17
|
//
|
|
@@ -188,8 +189,8 @@ export async function getApprovalPolicy(): Promise<DispatchApprovalPolicy> {
|
|
|
188
189
|
async function applyApprovalPolicy(
|
|
189
190
|
input: DispatchApprovalPolicy,
|
|
190
191
|
actor = currentOwnerEmail(),
|
|
192
|
+
orgId = currentOrgId(),
|
|
191
193
|
) {
|
|
192
|
-
const orgId = currentOrgId();
|
|
193
194
|
if (!orgId) {
|
|
194
195
|
throw new Error(
|
|
195
196
|
"Dispatch approval settings require an active organization",
|
|
@@ -209,7 +210,10 @@ async function applyApprovalPolicy(
|
|
|
209
210
|
metadata: input,
|
|
210
211
|
actor,
|
|
211
212
|
});
|
|
212
|
-
return
|
|
213
|
+
return {
|
|
214
|
+
enabled: input.enabled,
|
|
215
|
+
approverEmails: input.approverEmails,
|
|
216
|
+
};
|
|
213
217
|
}
|
|
214
218
|
|
|
215
219
|
export async function setApprovalPolicy(input: DispatchApprovalPolicy) {
|
|
@@ -564,6 +568,7 @@ async function applyApprovedRequest(request: DispatchApprovalRequest) {
|
|
|
564
568
|
return applyApprovalPolicy(
|
|
565
569
|
payload,
|
|
566
570
|
request.reviewedBy || currentOwnerEmail(),
|
|
571
|
+
requestCtx.orgId,
|
|
567
572
|
);
|
|
568
573
|
}
|
|
569
574
|
if (request.changeType === "dream-proposal.apply") {
|
|
@@ -610,22 +615,78 @@ export async function approveRequest(requestId: string) {
|
|
|
610
615
|
const ctx = requireDispatchCtx();
|
|
611
616
|
const request = await getApprovalRequest(requestId, ctx);
|
|
612
617
|
if (!request) throw new Error("Approval request not found");
|
|
613
|
-
|
|
614
|
-
throw new Error("Only pending approvals can be approved");
|
|
615
|
-
}
|
|
618
|
+
|
|
616
619
|
const timestamp = now();
|
|
617
|
-
|
|
620
|
+
const staleApplyingBefore = timestamp - APPROVAL_APPLY_LEASE_MS;
|
|
621
|
+
// Fence the transition on the current status so a concurrent approve can't
|
|
622
|
+
// both win. A crashed worker can leave its lease in "applying", so reclaim
|
|
623
|
+
// only a lease that has been idle for the full lease interval.
|
|
624
|
+
const claimed = await db
|
|
625
|
+
.update(schema.dispatchApprovalRequests)
|
|
626
|
+
.set({
|
|
627
|
+
status: "applying",
|
|
628
|
+
updatedAt: timestamp,
|
|
629
|
+
})
|
|
630
|
+
.where(
|
|
631
|
+
and(
|
|
632
|
+
eq(schema.dispatchApprovalRequests.id, requestId),
|
|
633
|
+
ctxScope(schema.dispatchApprovalRequests, ctx),
|
|
634
|
+
or(
|
|
635
|
+
eq(schema.dispatchApprovalRequests.status, "pending"),
|
|
636
|
+
and(
|
|
637
|
+
eq(schema.dispatchApprovalRequests.status, "applying"),
|
|
638
|
+
sql`${schema.dispatchApprovalRequests.updatedAt} < ${staleApplyingBefore}`,
|
|
639
|
+
),
|
|
640
|
+
),
|
|
641
|
+
),
|
|
642
|
+
)
|
|
643
|
+
.returning();
|
|
644
|
+
|
|
645
|
+
if (claimed.length === 0) {
|
|
646
|
+
const current = (await getApprovalRequest(requestId, ctx)) ?? request;
|
|
647
|
+
if (current.status === "applying") {
|
|
648
|
+
throw new Error("Approval is already being applied");
|
|
649
|
+
}
|
|
650
|
+
return current;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const applying = claimed[0];
|
|
654
|
+
try {
|
|
655
|
+
await applyApprovedRequest(applying);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
// Applying a request can have succeeded partially before throwing. Do not
|
|
658
|
+
// return it to pending: that would allow an immediate retry to duplicate a
|
|
659
|
+
// non-idempotent effect. Keep the lease until it becomes stale, which
|
|
660
|
+
// serializes any recovery attempt behind the same fencing rule.
|
|
661
|
+
await db
|
|
662
|
+
.update(schema.dispatchApprovalRequests)
|
|
663
|
+
.set({ updatedAt: now() })
|
|
664
|
+
.where(
|
|
665
|
+
and(
|
|
666
|
+
eq(schema.dispatchApprovalRequests.id, requestId),
|
|
667
|
+
ctxScope(schema.dispatchApprovalRequests, ctx),
|
|
668
|
+
eq(schema.dispatchApprovalRequests.status, "applying"),
|
|
669
|
+
),
|
|
670
|
+
);
|
|
671
|
+
throw error;
|
|
672
|
+
}
|
|
673
|
+
const [updated] = await db
|
|
618
674
|
.update(schema.dispatchApprovalRequests)
|
|
619
675
|
.set({
|
|
620
676
|
status: "approved",
|
|
621
677
|
reviewedBy: currentOwnerEmail(),
|
|
622
678
|
reviewedAt: timestamp,
|
|
623
|
-
updatedAt:
|
|
679
|
+
updatedAt: now(),
|
|
624
680
|
})
|
|
625
|
-
.where(
|
|
626
|
-
|
|
681
|
+
.where(
|
|
682
|
+
and(
|
|
683
|
+
eq(schema.dispatchApprovalRequests.id, requestId),
|
|
684
|
+
ctxScope(schema.dispatchApprovalRequests, ctx),
|
|
685
|
+
eq(schema.dispatchApprovalRequests.status, "applying"),
|
|
686
|
+
),
|
|
687
|
+
)
|
|
688
|
+
.returning();
|
|
627
689
|
if (!updated) throw new Error("Approval request disappeared");
|
|
628
|
-
await applyApprovedRequest(updated);
|
|
629
690
|
await recordAudit({
|
|
630
691
|
action: "approval.approved",
|
|
631
692
|
targetType: updated.targetType,
|
|
@@ -638,13 +699,12 @@ export async function approveRequest(requestId: string) {
|
|
|
638
699
|
|
|
639
700
|
export async function rejectRequest(requestId: string, reason?: string | null) {
|
|
640
701
|
const db = getDb();
|
|
641
|
-
const
|
|
702
|
+
const ctx = requireDispatchCtx();
|
|
703
|
+
const request = await getApprovalRequest(requestId, ctx);
|
|
642
704
|
if (!request) throw new Error("Approval request not found");
|
|
643
|
-
|
|
644
|
-
throw new Error("Only pending approvals can be rejected");
|
|
645
|
-
}
|
|
705
|
+
|
|
646
706
|
const timestamp = now();
|
|
647
|
-
await db
|
|
707
|
+
const claimed = await db
|
|
648
708
|
.update(schema.dispatchApprovalRequests)
|
|
649
709
|
.set({
|
|
650
710
|
status: "rejected",
|
|
@@ -652,15 +712,28 @@ export async function rejectRequest(requestId: string, reason?: string | null) {
|
|
|
652
712
|
reviewedAt: timestamp,
|
|
653
713
|
updatedAt: timestamp,
|
|
654
714
|
})
|
|
655
|
-
.where(
|
|
715
|
+
.where(
|
|
716
|
+
and(
|
|
717
|
+
eq(schema.dispatchApprovalRequests.id, requestId),
|
|
718
|
+
ctxScope(schema.dispatchApprovalRequests, ctx),
|
|
719
|
+
eq(schema.dispatchApprovalRequests.status, "pending"),
|
|
720
|
+
),
|
|
721
|
+
)
|
|
722
|
+
.returning();
|
|
723
|
+
|
|
724
|
+
if (claimed.length === 0) {
|
|
725
|
+
return (await getApprovalRequest(requestId, ctx)) ?? request;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const updated = claimed[0];
|
|
656
729
|
await recordAudit({
|
|
657
730
|
action: "approval.rejected",
|
|
658
|
-
targetType:
|
|
731
|
+
targetType: updated.targetType,
|
|
659
732
|
targetId: requestId,
|
|
660
|
-
summary: `Rejected ${
|
|
661
|
-
metadata: { request, reason: reason || null },
|
|
733
|
+
summary: `Rejected ${updated.summary}`,
|
|
734
|
+
metadata: { request: updated, reason: reason || null },
|
|
662
735
|
});
|
|
663
|
-
return
|
|
736
|
+
return updated;
|
|
664
737
|
}
|
|
665
738
|
|
|
666
739
|
export async function createLinkToken(platform: string) {
|
|
@@ -1,15 +1,42 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
execute: vi.fn(),
|
|
5
|
+
getOrgSetting: vi.fn(),
|
|
6
|
+
getUserSetting: vi.fn(),
|
|
7
|
+
putOrgSetting: vi.fn(),
|
|
8
|
+
putUserSetting: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("@agent-native/core/db", () => ({
|
|
12
|
+
getDbExec: () => ({ execute: mocks.execute }),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("@agent-native/core/settings", () => ({
|
|
16
|
+
getOrgSetting: mocks.getOrgSetting,
|
|
17
|
+
getUserSetting: mocks.getUserSetting,
|
|
18
|
+
putOrgSetting: mocks.putOrgSetting,
|
|
19
|
+
putUserSetting: mocks.putUserSetting,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { runWithRequestContext } from "@agent-native/core/server";
|
|
2
23
|
|
|
3
24
|
import {
|
|
4
25
|
isAppAllowedByMcpAccess,
|
|
5
26
|
normalizeMcpAppAccessSettings,
|
|
27
|
+
setDispatchMcpAppAccessSettings,
|
|
6
28
|
} from "./mcp-access-store.js";
|
|
7
29
|
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
vi.clearAllMocks();
|
|
32
|
+
mocks.execute.mockResolvedValue({ rows: [] });
|
|
33
|
+
});
|
|
34
|
+
|
|
8
35
|
describe("normalizeMcpAppAccessSettings", () => {
|
|
9
|
-
it("defaults to
|
|
36
|
+
it("defaults to Dispatch only", () => {
|
|
10
37
|
expect(normalizeMcpAppAccessSettings(null)).toEqual({
|
|
11
|
-
mode: "
|
|
12
|
-
selectedAppIds: [],
|
|
38
|
+
mode: "selected-apps",
|
|
39
|
+
selectedAppIds: ["dispatch"],
|
|
13
40
|
updatedAt: undefined,
|
|
14
41
|
updatedBy: undefined,
|
|
15
42
|
});
|
|
@@ -32,6 +59,91 @@ describe("normalizeMcpAppAccessSettings", () => {
|
|
|
32
59
|
});
|
|
33
60
|
});
|
|
34
61
|
|
|
62
|
+
describe("setDispatchMcpAppAccessSettings", () => {
|
|
63
|
+
it.each(["owner", "admin"])(
|
|
64
|
+
"lets an organization %s change the app grants",
|
|
65
|
+
async (role) => {
|
|
66
|
+
mocks.execute.mockResolvedValue({ rows: [{ role }] });
|
|
67
|
+
|
|
68
|
+
const result = await runWithRequestContext(
|
|
69
|
+
{ userEmail: "manager@example.test", orgId: "org-1" },
|
|
70
|
+
() =>
|
|
71
|
+
setDispatchMcpAppAccessSettings({
|
|
72
|
+
mode: "selected-apps",
|
|
73
|
+
selectedAppIds: ["dispatch", "analytics"],
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(mocks.execute).toHaveBeenCalledWith({
|
|
78
|
+
sql: expect.stringContaining("FROM org_members"),
|
|
79
|
+
args: ["org-1", "manager@example.test"],
|
|
80
|
+
});
|
|
81
|
+
expect(mocks.putOrgSetting).toHaveBeenCalledWith(
|
|
82
|
+
"org-1",
|
|
83
|
+
"dispatch-mcp-app-access",
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
mode: "selected-apps",
|
|
86
|
+
selectedAppIds: ["dispatch", "analytics"],
|
|
87
|
+
updatedBy: "manager@example.test",
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
expect(result.mode).toBe("selected-apps");
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
it("rejects an organization member without changing grants", async () => {
|
|
95
|
+
mocks.execute.mockResolvedValue({ rows: [{ role: "member" }] });
|
|
96
|
+
|
|
97
|
+
await expect(
|
|
98
|
+
runWithRequestContext(
|
|
99
|
+
{ userEmail: "member@example.test", orgId: "org-1" },
|
|
100
|
+
() =>
|
|
101
|
+
setDispatchMcpAppAccessSettings({
|
|
102
|
+
mode: "all-apps",
|
|
103
|
+
}),
|
|
104
|
+
),
|
|
105
|
+
).rejects.toMatchObject({
|
|
106
|
+
message: expect.stringMatching(/Only organization owners and admins/),
|
|
107
|
+
statusCode: 403,
|
|
108
|
+
});
|
|
109
|
+
expect(mocks.putOrgSetting).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("fails closed when organization membership cannot be verified", async () => {
|
|
113
|
+
mocks.execute.mockRejectedValue(new Error("org lookup unavailable"));
|
|
114
|
+
|
|
115
|
+
await expect(
|
|
116
|
+
runWithRequestContext(
|
|
117
|
+
{ userEmail: "owner@example.test", orgId: "org-1" },
|
|
118
|
+
() =>
|
|
119
|
+
setDispatchMcpAppAccessSettings({
|
|
120
|
+
mode: "all-apps",
|
|
121
|
+
}),
|
|
122
|
+
),
|
|
123
|
+
).rejects.toThrow(/Only organization owners and admins/);
|
|
124
|
+
expect(mocks.putOrgSetting).not.toHaveBeenCalled();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("lets an authenticated solo user manage only their own grants", async () => {
|
|
128
|
+
await runWithRequestContext({ userEmail: "solo@example.test" }, () =>
|
|
129
|
+
setDispatchMcpAppAccessSettings({
|
|
130
|
+
mode: "selected-apps",
|
|
131
|
+
selectedAppIds: [],
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(mocks.execute).not.toHaveBeenCalled();
|
|
136
|
+
expect(mocks.putUserSetting).toHaveBeenCalledWith(
|
|
137
|
+
"solo@example.test",
|
|
138
|
+
"dispatch-mcp-app-access",
|
|
139
|
+
expect.objectContaining({
|
|
140
|
+
mode: "selected-apps",
|
|
141
|
+
selectedAppIds: [],
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
35
147
|
describe("isAppAllowedByMcpAccess", () => {
|
|
36
148
|
it("allows every app in all-apps mode", () => {
|
|
37
149
|
expect(
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getDbExec } from "@agent-native/core/db";
|
|
1
2
|
import {
|
|
2
3
|
getRequestOrgId,
|
|
3
4
|
getRequestUserEmail,
|
|
@@ -20,6 +21,16 @@ export interface DispatchMcpAppAccessSettings {
|
|
|
20
21
|
updatedBy?: string;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
class McpAppAccessError extends Error {
|
|
25
|
+
statusCode: number;
|
|
26
|
+
|
|
27
|
+
constructor(message: string, statusCode: number) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "McpAppAccessError";
|
|
30
|
+
this.statusCode = statusCode;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
interface AccessScope {
|
|
24
35
|
kind: "org" | "user";
|
|
25
36
|
id: string;
|
|
@@ -45,10 +56,11 @@ export function normalizeMcpAppAccessSettings(
|
|
|
45
56
|
raw && typeof raw === "object" && !Array.isArray(raw)
|
|
46
57
|
? (raw as Record<string, unknown>)
|
|
47
58
|
: {};
|
|
48
|
-
const mode = record.mode === "
|
|
59
|
+
const mode = record.mode === "all-apps" ? "all-apps" : "selected-apps";
|
|
49
60
|
return {
|
|
50
61
|
mode,
|
|
51
|
-
selectedAppIds:
|
|
62
|
+
selectedAppIds:
|
|
63
|
+
raw == null ? ["dispatch"] : uniqueAppIds(record.selectedAppIds),
|
|
52
64
|
updatedAt:
|
|
53
65
|
typeof record.updatedAt === "string" ? record.updatedAt : undefined,
|
|
54
66
|
updatedBy:
|
|
@@ -64,6 +76,28 @@ function currentAccessScope(): AccessScope {
|
|
|
64
76
|
return { kind: "user", id: actor, actor };
|
|
65
77
|
}
|
|
66
78
|
|
|
79
|
+
async function assertCanManageMcpAppAccess(scope: AccessScope): Promise<void> {
|
|
80
|
+
if (scope.kind === "user") return;
|
|
81
|
+
|
|
82
|
+
let role: unknown = null;
|
|
83
|
+
try {
|
|
84
|
+
const { rows } = await getDbExec().execute({
|
|
85
|
+
sql: `SELECT role FROM org_members WHERE org_id = ? AND LOWER(email) = ? LIMIT 1`,
|
|
86
|
+
args: [scope.id, scope.actor.toLowerCase()],
|
|
87
|
+
});
|
|
88
|
+
role = rows[0]?.role;
|
|
89
|
+
} catch {
|
|
90
|
+
// Fail closed when org membership cannot be verified.
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (role !== "owner" && role !== "admin") {
|
|
94
|
+
throw new McpAppAccessError(
|
|
95
|
+
"Only organization owners and admins can change Dispatch MCP app access.",
|
|
96
|
+
403,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
67
101
|
export async function getDispatchMcpAppAccessSettings(): Promise<DispatchMcpAppAccessSettings> {
|
|
68
102
|
const scope = currentAccessScope();
|
|
69
103
|
const raw =
|
|
@@ -78,6 +112,7 @@ export async function setDispatchMcpAppAccessSettings(input: {
|
|
|
78
112
|
selectedAppIds?: string[];
|
|
79
113
|
}): Promise<DispatchMcpAppAccessSettings> {
|
|
80
114
|
const scope = currentAccessScope();
|
|
115
|
+
await assertCanManageMcpAppAccess(scope);
|
|
81
116
|
const next: DispatchMcpAppAccessSettings = {
|
|
82
117
|
mode: input.mode,
|
|
83
118
|
selectedAppIds: uniqueAppIds(input.selectedAppIds),
|
|
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
12
12
|
managerStop: vi.fn(),
|
|
13
13
|
managerCallTool: vi.fn(),
|
|
14
14
|
managerConstructor: vi.fn(),
|
|
15
|
+
callAgent: vi.fn(),
|
|
15
16
|
signA2AToken: vi.fn(),
|
|
16
17
|
getOrgA2ASecret: vi.fn(),
|
|
17
18
|
getOrgDomain: vi.fn(),
|
|
@@ -39,7 +40,7 @@ vi.mock("@agent-native/core/server", async (importOriginal) => {
|
|
|
39
40
|
});
|
|
40
41
|
|
|
41
42
|
vi.mock("@agent-native/core/a2a", () => ({
|
|
42
|
-
callAgent:
|
|
43
|
+
callAgent: mocks.callAgent,
|
|
43
44
|
signA2AToken: mocks.signA2AToken,
|
|
44
45
|
}));
|
|
45
46
|
|
|
@@ -74,6 +75,7 @@ import { runWithRequestContext } from "@agent-native/core/server";
|
|
|
74
75
|
|
|
75
76
|
import {
|
|
76
77
|
createGrantedDispatchMcpEmbedSession,
|
|
78
|
+
askGrantedDispatchMcpApp,
|
|
77
79
|
listGrantedDispatchMcpApps,
|
|
78
80
|
listGrantedDispatchMcpAppOrigins,
|
|
79
81
|
openGrantedDispatchMcpApp,
|
|
@@ -90,8 +92,8 @@ const analyticsAgent = {
|
|
|
90
92
|
|
|
91
93
|
beforeEach(() => {
|
|
92
94
|
mocks.discoverAgents.mockResolvedValue([analyticsAgent]);
|
|
93
|
-
mocks.getUserSetting.mockResolvedValue(
|
|
94
|
-
mocks.getOrgSetting.mockResolvedValue(
|
|
95
|
+
mocks.getUserSetting.mockResolvedValue({ mode: "all-apps" });
|
|
96
|
+
mocks.getOrgSetting.mockResolvedValue({ mode: "all-apps" });
|
|
95
97
|
mocks.createEmbedSessionTicket.mockResolvedValue({
|
|
96
98
|
ticket: "ticket-123",
|
|
97
99
|
ticketHash: "hash-123",
|
|
@@ -104,6 +106,7 @@ beforeEach(() => {
|
|
|
104
106
|
startUrl: "http://localhost:8086/_agent-native/embed/start?ticket=remote",
|
|
105
107
|
},
|
|
106
108
|
});
|
|
109
|
+
mocks.callAgent.mockResolvedValue("Created the requested dashboard.");
|
|
107
110
|
mocks.signA2AToken.mockResolvedValue("signed-token");
|
|
108
111
|
mocks.getOrgA2ASecret.mockResolvedValue(null);
|
|
109
112
|
mocks.getOrgDomain.mockResolvedValue(null);
|
|
@@ -115,7 +118,21 @@ afterEach(() => {
|
|
|
115
118
|
});
|
|
116
119
|
|
|
117
120
|
describe("Dispatch MCP gateway app discovery", () => {
|
|
121
|
+
it("defaults to exposing Dispatch only until an owner or admin grants more apps", async () => {
|
|
122
|
+
mocks.getUserSetting.mockResolvedValue(null);
|
|
123
|
+
const apps = await runWithRequestContext(
|
|
124
|
+
{
|
|
125
|
+
userEmail: "owner@example.test",
|
|
126
|
+
requestOrigin: "http://localhost:8092",
|
|
127
|
+
},
|
|
128
|
+
() => listGrantedDispatchMcpApps(),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(apps.map((app) => app.id)).toEqual(["dispatch"]);
|
|
132
|
+
});
|
|
133
|
+
|
|
118
134
|
it("includes Dispatch itself so agents can target extension routes", async () => {
|
|
135
|
+
mocks.getUserSetting.mockResolvedValue({ mode: "all-apps" });
|
|
119
136
|
const apps = await runWithRequestContext(
|
|
120
137
|
{
|
|
121
138
|
userEmail: "owner@example.test",
|
|
@@ -222,6 +239,7 @@ describe("Dispatch MCP gateway app discovery", () => {
|
|
|
222
239
|
color: "#111827",
|
|
223
240
|
},
|
|
224
241
|
]);
|
|
242
|
+
mocks.getUserSetting.mockResolvedValue({ mode: "all-apps" });
|
|
225
243
|
|
|
226
244
|
await expect(
|
|
227
245
|
runWithRequestContext(
|
|
@@ -235,6 +253,64 @@ describe("Dispatch MCP gateway app discovery", () => {
|
|
|
235
253
|
});
|
|
236
254
|
});
|
|
237
255
|
|
|
256
|
+
describe("askGrantedDispatchMcpApp", () => {
|
|
257
|
+
it("routes the authenticated user and active org identity to the granted app", async () => {
|
|
258
|
+
mocks.getOrgSetting.mockResolvedValue({
|
|
259
|
+
mode: "selected-apps",
|
|
260
|
+
selectedAppIds: ["analytics"],
|
|
261
|
+
});
|
|
262
|
+
mocks.getOrgDomain.mockResolvedValue("builder.io");
|
|
263
|
+
mocks.getOrgA2ASecret.mockResolvedValue("org-specific-secret");
|
|
264
|
+
|
|
265
|
+
const result = await runWithRequestContext(
|
|
266
|
+
{
|
|
267
|
+
userEmail: "owner@example.test",
|
|
268
|
+
orgId: "org-1",
|
|
269
|
+
requestOrigin: "http://localhost:8092",
|
|
270
|
+
},
|
|
271
|
+
() =>
|
|
272
|
+
askGrantedDispatchMcpApp(
|
|
273
|
+
"analytics",
|
|
274
|
+
"Build a weekly active users dashboard.",
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
expect(mocks.callAgent).toHaveBeenCalledWith(
|
|
279
|
+
"http://localhost:8086",
|
|
280
|
+
"Build a weekly active users dashboard.",
|
|
281
|
+
{
|
|
282
|
+
userEmail: "owner@example.test",
|
|
283
|
+
orgDomain: "builder.io",
|
|
284
|
+
orgSecret: "org-specific-secret",
|
|
285
|
+
timeoutMs: 5 * 60_000,
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
expect(result).toEqual({
|
|
289
|
+
app: "analytics",
|
|
290
|
+
routedVia: "a2a",
|
|
291
|
+
response: "Created the requested dashboard.",
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("rejects delegation to an app outside the grant", async () => {
|
|
296
|
+
mocks.getUserSetting.mockResolvedValue({
|
|
297
|
+
mode: "selected-apps",
|
|
298
|
+
selectedAppIds: ["dispatch"],
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await expect(
|
|
302
|
+
runWithRequestContext(
|
|
303
|
+
{
|
|
304
|
+
userEmail: "owner@example.test",
|
|
305
|
+
requestOrigin: "http://localhost:8092",
|
|
306
|
+
},
|
|
307
|
+
() => askGrantedDispatchMcpApp("analytics", "Show signups."),
|
|
308
|
+
),
|
|
309
|
+
).rejects.toThrow(/not granted/);
|
|
310
|
+
expect(mocks.callAgent).not.toHaveBeenCalled();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
238
314
|
describe("openGrantedDispatchMcpApp", () => {
|
|
239
315
|
it("opens Dispatch extension routes through the Dispatch app id", async () => {
|
|
240
316
|
const result = await runWithRequestContext(
|