@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
package/src/actions/list_apps.ts
CHANGED
|
@@ -12,17 +12,25 @@ export default defineAction({
|
|
|
12
12
|
parallelSafe: true,
|
|
13
13
|
run: async () => {
|
|
14
14
|
const apps = await listGrantedDispatchMcpApps();
|
|
15
|
+
const appSummaries = apps.map((app) => ({
|
|
16
|
+
id: app.id,
|
|
17
|
+
name: app.name,
|
|
18
|
+
description: app.description,
|
|
19
|
+
url: app.url,
|
|
20
|
+
running: true,
|
|
21
|
+
source: "dispatch-mcp-grant",
|
|
22
|
+
}));
|
|
15
23
|
return {
|
|
16
24
|
workspace: true,
|
|
17
25
|
gateway: "dispatch",
|
|
18
|
-
apps:
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
})
|
|
26
|
+
apps: appSummaries,
|
|
27
|
+
// MCP model-visible results are deliberately text-only unless a tool is
|
|
28
|
+
// app-only or opens an MCP App. Supplying a compact, valid JSON message
|
|
29
|
+
// keeps large app catalogs machine-readable instead of letting the
|
|
30
|
+
// generic text limiter cut the full result into invalid JSON.
|
|
31
|
+
message: JSON.stringify({
|
|
32
|
+
apps: appSummaries.map(({ id, name }) => ({ id, name })),
|
|
33
|
+
}),
|
|
26
34
|
};
|
|
27
35
|
},
|
|
28
36
|
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the org-scope authorization added to provider-api-register.
|
|
3
|
+
*
|
|
4
|
+
* Plan 014 (advisor-plans/014-custom-registry-scope-authorization.md): this
|
|
5
|
+
* action previously let ANY authenticated org member upsert/delete an
|
|
6
|
+
* ORG-scoped custom API provider with no owner/admin check. These tests
|
|
7
|
+
* assert that org-scope upsert/delete now require the caller to be an org
|
|
8
|
+
* owner or admin, while user-scope calls and org-scope reads remain
|
|
9
|
+
* unaffected.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
13
|
+
|
|
14
|
+
const mocks = vi.hoisted(() => ({
|
|
15
|
+
getCredentialContext: vi.fn(),
|
|
16
|
+
execute: vi.fn(),
|
|
17
|
+
upsertCustomProvider: vi.fn(),
|
|
18
|
+
deleteCustomProvider: vi.fn(),
|
|
19
|
+
listCustomProviders: vi.fn(),
|
|
20
|
+
getCustomProvider: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock("@agent-native/core/server", () => ({
|
|
24
|
+
getCredentialContext: mocks.getCredentialContext,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock("@agent-native/core/db", () => ({
|
|
28
|
+
getDbExec: () => ({ execute: mocks.execute }),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// Keep the real `assertCanMutateCustomProviderScope` (it's the guard under
|
|
32
|
+
// test's downstream enforcement point) and only mock the DB-backed CRUD
|
|
33
|
+
// functions, mirroring the `importOriginal` partial-mock pattern used in
|
|
34
|
+
// packages/dispatch/src/server/lib/vault-store.spec.ts.
|
|
35
|
+
vi.mock("@agent-native/core/provider-api", async (importOriginal) => {
|
|
36
|
+
const actual =
|
|
37
|
+
await importOriginal<typeof import("@agent-native/core/provider-api")>();
|
|
38
|
+
return {
|
|
39
|
+
...actual,
|
|
40
|
+
upsertCustomProvider: mocks.upsertCustomProvider,
|
|
41
|
+
deleteCustomProvider: mocks.deleteCustomProvider,
|
|
42
|
+
listCustomProviders: mocks.listCustomProviders,
|
|
43
|
+
getCustomProvider: mocks.getCustomProvider,
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const action = (await import("./provider-api-register.js")).default;
|
|
48
|
+
|
|
49
|
+
function mockOrgRole(role: string | null): void {
|
|
50
|
+
mocks.execute.mockResolvedValue({ rows: role ? [{ role }] : [] });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const baseUpsertInput = {
|
|
54
|
+
operation: "upsert" as const,
|
|
55
|
+
id: "my-api",
|
|
56
|
+
label: "My API",
|
|
57
|
+
baseUrl: "https://api.example.com",
|
|
58
|
+
auth: { type: "none" as const },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
describe("provider-api-register org-scope authorization", () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
vi.clearAllMocks();
|
|
64
|
+
mocks.getCredentialContext.mockReturnValue({
|
|
65
|
+
userEmail: "member@example.com",
|
|
66
|
+
orgId: "org-a",
|
|
67
|
+
});
|
|
68
|
+
mocks.upsertCustomProvider.mockResolvedValue("my-api");
|
|
69
|
+
mocks.deleteCustomProvider.mockResolvedValue(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("rejects an org-scope upsert from a plain member", async () => {
|
|
73
|
+
mockOrgRole("member");
|
|
74
|
+
|
|
75
|
+
await expect(
|
|
76
|
+
action.run({ ...baseUpsertInput, scope: "org" }),
|
|
77
|
+
).rejects.toThrow(/owners and admins/i);
|
|
78
|
+
expect(mocks.upsertCustomProvider).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("rejects an org-scope delete from a plain member", async () => {
|
|
82
|
+
mockOrgRole("member");
|
|
83
|
+
|
|
84
|
+
await expect(
|
|
85
|
+
action.run({ operation: "delete", id: "my-api", scope: "org" }),
|
|
86
|
+
).rejects.toThrow(/owners and admins/i);
|
|
87
|
+
expect(mocks.deleteCustomProvider).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects an org-scope delete when the caller has no membership row", async () => {
|
|
91
|
+
mockOrgRole(null);
|
|
92
|
+
|
|
93
|
+
await expect(
|
|
94
|
+
action.run({ operation: "delete", id: "my-api", scope: "org" }),
|
|
95
|
+
).rejects.toThrow(/owners and admins/i);
|
|
96
|
+
expect(mocks.deleteCustomProvider).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("allows an org-scope upsert from an owner and passes orgRole through", async () => {
|
|
100
|
+
mockOrgRole("owner");
|
|
101
|
+
|
|
102
|
+
await action.run({ ...baseUpsertInput, scope: "org" });
|
|
103
|
+
|
|
104
|
+
expect(mocks.upsertCustomProvider).toHaveBeenCalledWith(
|
|
105
|
+
expect.objectContaining({
|
|
106
|
+
scope: "org",
|
|
107
|
+
scopeId: "org-a",
|
|
108
|
+
orgRole: "owner",
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("allows an org-scope delete from an admin and passes orgRole through", async () => {
|
|
114
|
+
mockOrgRole("admin");
|
|
115
|
+
|
|
116
|
+
await action.run({ operation: "delete", id: "my-api", scope: "org" });
|
|
117
|
+
|
|
118
|
+
expect(mocks.deleteCustomProvider).toHaveBeenCalledWith(
|
|
119
|
+
"org",
|
|
120
|
+
"org-a",
|
|
121
|
+
"my-api",
|
|
122
|
+
"admin",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("does not require an org role for user-scope upsert/delete", async () => {
|
|
127
|
+
await action.run({ ...baseUpsertInput, scope: "user" });
|
|
128
|
+
await action.run({ operation: "delete", id: "my-api", scope: "user" });
|
|
129
|
+
|
|
130
|
+
expect(mocks.upsertCustomProvider).toHaveBeenCalledWith(
|
|
131
|
+
expect.objectContaining({
|
|
132
|
+
scope: "user",
|
|
133
|
+
scopeId: "member@example.com",
|
|
134
|
+
orgRole: null,
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
expect(mocks.deleteCustomProvider).toHaveBeenCalledWith(
|
|
138
|
+
"user",
|
|
139
|
+
"member@example.com",
|
|
140
|
+
"my-api",
|
|
141
|
+
null,
|
|
142
|
+
);
|
|
143
|
+
// No membership lookup should even happen for user-scope calls.
|
|
144
|
+
expect(mocks.execute).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("allows an org-scope upsert/delete when the caller has no active org", async () => {
|
|
148
|
+
// No org at all — `resolveOrgId` never wired, or a genuinely org-less
|
|
149
|
+
// solo user. `scopeId` collapses to the caller's own email (same as
|
|
150
|
+
// scope: "user"), so there's no other org member to protect against;
|
|
151
|
+
// this must not hard-reject the action's own default scope.
|
|
152
|
+
mocks.getCredentialContext.mockReturnValue({
|
|
153
|
+
userEmail: "solo@example.com",
|
|
154
|
+
orgId: null,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await action.run({ ...baseUpsertInput, scope: "org" });
|
|
158
|
+
await action.run({ operation: "delete", id: "my-api", scope: "org" });
|
|
159
|
+
|
|
160
|
+
expect(mocks.upsertCustomProvider).toHaveBeenCalledWith(
|
|
161
|
+
expect.objectContaining({
|
|
162
|
+
scope: "org",
|
|
163
|
+
scopeId: "solo@example.com",
|
|
164
|
+
orgRole: "owner",
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
expect(mocks.deleteCustomProvider).toHaveBeenCalledWith(
|
|
168
|
+
"org",
|
|
169
|
+
"solo@example.com",
|
|
170
|
+
"my-api",
|
|
171
|
+
"owner",
|
|
172
|
+
);
|
|
173
|
+
// No org, so no membership lookup should happen either.
|
|
174
|
+
expect(mocks.execute).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("does not gate org-scope list/get reads on org role", async () => {
|
|
178
|
+
mocks.listCustomProviders.mockResolvedValue([]);
|
|
179
|
+
mocks.getCustomProvider.mockResolvedValue(null);
|
|
180
|
+
|
|
181
|
+
await action.run({ operation: "list", scope: "org" });
|
|
182
|
+
await action.run({ operation: "get", id: "my-api", scope: "org" });
|
|
183
|
+
|
|
184
|
+
expect(mocks.listCustomProviders).toHaveBeenCalledWith("org", "org-a");
|
|
185
|
+
expect(mocks.getCustomProvider).toHaveBeenCalledWith(
|
|
186
|
+
"org",
|
|
187
|
+
"org-a",
|
|
188
|
+
"my-api",
|
|
189
|
+
);
|
|
190
|
+
// Reads are intentionally out of scope for the role gate (plan 014).
|
|
191
|
+
expect(mocks.execute).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -1,13 +1,50 @@
|
|
|
1
1
|
import { defineAction } from "@agent-native/core";
|
|
2
|
+
import { getDbExec } from "@agent-native/core/db";
|
|
2
3
|
import {
|
|
3
4
|
upsertCustomProvider,
|
|
4
5
|
deleteCustomProvider,
|
|
5
6
|
listCustomProviders,
|
|
6
7
|
getCustomProvider,
|
|
8
|
+
assertCanMutateCustomProviderScope,
|
|
7
9
|
} from "@agent-native/core/provider-api";
|
|
8
10
|
import { getCredentialContext } from "@agent-native/core/server";
|
|
9
11
|
import { z } from "zod";
|
|
10
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the caller's role in a specific org, straight from `org_members`.
|
|
15
|
+
*
|
|
16
|
+
* `getCredentialContext()` (from `@agent-native/core/server`) only exposes
|
|
17
|
+
* `{ userEmail, orgId }` — no role. `getOrgContext()` (from
|
|
18
|
+
* `@agent-native/core/org`) resolves role but requires an `H3Event`, which
|
|
19
|
+
* `defineAction` handlers are not given. This mirrors the established
|
|
20
|
+
* no-event role-lookup idiom already used for org-admin gating inside
|
|
21
|
+
* agent-callable/background code (see `isCurrentUserOrgAdmin` in
|
|
22
|
+
* `packages/core/src/jobs/tools.ts`, `getViewerOrgRole` in
|
|
23
|
+
* `packages/dispatch/src/server/lib/usage-metrics-store.ts`, and the same
|
|
24
|
+
* query in `packages/core/src/mcp/actions/service-token-access.ts`) — same
|
|
25
|
+
* SQL, same fail-closed-to-null-on-error semantics. Returns null (never an
|
|
26
|
+
* org role) on any lookup error or when the caller has no membership row in
|
|
27
|
+
* this org.
|
|
28
|
+
*/
|
|
29
|
+
async function resolveCallerOrgRole(
|
|
30
|
+
orgId: string | null,
|
|
31
|
+
email: string,
|
|
32
|
+
): Promise<string | null> {
|
|
33
|
+
if (!orgId) return null;
|
|
34
|
+
try {
|
|
35
|
+
const client = getDbExec();
|
|
36
|
+
const { rows } = await client.execute({
|
|
37
|
+
sql: `SELECT role FROM org_members WHERE org_id = ? AND LOWER(email) = ? LIMIT 1`,
|
|
38
|
+
args: [orgId, email.toLowerCase()],
|
|
39
|
+
});
|
|
40
|
+
if (rows.length === 0) return null;
|
|
41
|
+
const role = (rows[0] as { role?: unknown }).role;
|
|
42
|
+
return typeof role === "string" && role ? role : null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
11
48
|
const AuthSchema = z.discriminatedUnion("type", [
|
|
12
49
|
z.object({
|
|
13
50
|
type: z.literal("none"),
|
|
@@ -138,6 +175,33 @@ After registration the provider appears in provider-api-catalog and can be used
|
|
|
138
175
|
const scopeId =
|
|
139
176
|
scope === "org" ? (ctx.orgId ?? ctx.userEmail) : ctx.userEmail;
|
|
140
177
|
|
|
178
|
+
// Only upsert/delete mutate state; list/get remain readable by any org
|
|
179
|
+
// member (scoping org-scoped reads is left as a follow-up — see plan
|
|
180
|
+
// 014). Resolve the caller's role in the *target* org (`scopeId`, which
|
|
181
|
+
// for scope === "org" is exactly `ctx.orgId`) and enforce owner/admin
|
|
182
|
+
// before allowing an org-scoped write. `assertCanMutateCustomProviderScope`
|
|
183
|
+
// is the single source of truth for this check and is also enforced a
|
|
184
|
+
// second time inside `upsertCustomProvider`/`deleteCustomProvider`
|
|
185
|
+
// themselves (defense in depth) — calling it here too gives a clear,
|
|
186
|
+
// early error before any other work happens.
|
|
187
|
+
//
|
|
188
|
+
// When the caller has no active org (`ctx.orgId` is null — a solo user,
|
|
189
|
+
// or an app that hasn't wired `resolveOrgId` at all), `scopeId` above
|
|
190
|
+
// already collapsed to `ctx.userEmail`: there is no shared org resource
|
|
191
|
+
// to protect, and no *other* caller can ever address that same scopeId
|
|
192
|
+
// (every other request's fallback is scoped to *its own* email). Treat
|
|
193
|
+
// that case like sole ownership of a personal bucket — consistent with
|
|
194
|
+
// `org/context.ts`'s auto-created personal org, which also assigns the
|
|
195
|
+
// user role "owner" — rather than hard-rejecting scope: "org" (the
|
|
196
|
+
// action's own default) for every solo user or org-less app.
|
|
197
|
+
let orgRole: string | null = null;
|
|
198
|
+
if ((operation === "upsert" || operation === "delete") && scope === "org") {
|
|
199
|
+
orgRole = ctx.orgId
|
|
200
|
+
? await resolveCallerOrgRole(ctx.orgId, ctx.userEmail)
|
|
201
|
+
: "owner";
|
|
202
|
+
assertCanMutateCustomProviderScope(scope, scopeId, orgRole);
|
|
203
|
+
}
|
|
204
|
+
|
|
141
205
|
if (operation === "list") {
|
|
142
206
|
const providers = await listCustomProviders(scope, scopeId);
|
|
143
207
|
return {
|
|
@@ -165,7 +229,7 @@ After registration the provider appears in provider-api-catalog and can be used
|
|
|
165
229
|
|
|
166
230
|
if (operation === "delete") {
|
|
167
231
|
if (!id) throw new Error("id is required for delete operation.");
|
|
168
|
-
const deleted = await deleteCustomProvider(scope, scopeId, id);
|
|
232
|
+
const deleted = await deleteCustomProvider(scope, scopeId, id, orgRole);
|
|
169
233
|
return { deleted, id };
|
|
170
234
|
}
|
|
171
235
|
|
|
@@ -186,6 +250,7 @@ After registration the provider appears in provider-api-catalog and can be used
|
|
|
186
250
|
allowedHostSuffixes,
|
|
187
251
|
defaultHeaders,
|
|
188
252
|
notes,
|
|
253
|
+
orgRole,
|
|
189
254
|
});
|
|
190
255
|
|
|
191
256
|
return {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
discoverAgents: vi.fn(),
|
|
5
|
+
setAccess: vi.fn(),
|
|
6
|
+
recordAudit: vi.fn(),
|
|
7
|
+
listAccess: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("@agent-native/core/server/agent-discovery", () => ({
|
|
11
|
+
discoverAgents: mocks.discoverAgents,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("../server/lib/mcp-access-store.js", () => ({
|
|
15
|
+
setDispatchMcpAppAccessSettings: mocks.setAccess,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("../server/lib/dispatch-store.js", () => ({
|
|
19
|
+
recordAudit: mocks.recordAudit,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("./list-mcp-app-access.js", () => ({
|
|
23
|
+
default: { run: mocks.listAccess },
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import setMcpAppAccess from "./set-mcp-app-access.js";
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
mocks.discoverAgents.mockResolvedValue([
|
|
31
|
+
{ id: "analytics" },
|
|
32
|
+
{ id: "slides" },
|
|
33
|
+
]);
|
|
34
|
+
mocks.setAccess.mockResolvedValue(undefined);
|
|
35
|
+
mocks.recordAudit.mockResolvedValue(undefined);
|
|
36
|
+
mocks.listAccess.mockResolvedValue({ mode: "selected-apps" });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("set-mcp-app-access", () => {
|
|
40
|
+
it("accepts Dispatch itself in selected-app mode", async () => {
|
|
41
|
+
await setMcpAppAccess.run({
|
|
42
|
+
mode: "selected-apps",
|
|
43
|
+
selectedAppIds: ["Dispatch", "slides"],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(mocks.setAccess).toHaveBeenCalledWith({
|
|
47
|
+
mode: "selected-apps",
|
|
48
|
+
selectedAppIds: ["dispatch", "slides"],
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("allows an owner or admin to revoke every app grant", async () => {
|
|
53
|
+
await setMcpAppAccess.run({
|
|
54
|
+
mode: "selected-apps",
|
|
55
|
+
selectedAppIds: [],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(mocks.setAccess).toHaveBeenCalledWith({
|
|
59
|
+
mode: "selected-apps",
|
|
60
|
+
selectedAppIds: [],
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("rejects unknown app ids before changing access", async () => {
|
|
65
|
+
await expect(
|
|
66
|
+
setMcpAppAccess.run({
|
|
67
|
+
mode: "selected-apps",
|
|
68
|
+
selectedAppIds: ["unknown"],
|
|
69
|
+
}),
|
|
70
|
+
).rejects.toThrow(/Unknown app/);
|
|
71
|
+
expect(mocks.setAccess).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -22,7 +22,7 @@ export default defineAction({
|
|
|
22
22
|
}),
|
|
23
23
|
run: async (args) => {
|
|
24
24
|
const agents = await discoverAgents("dispatch");
|
|
25
|
-
const knownIds = new Set(agents.map((agent) => agent.id));
|
|
25
|
+
const knownIds = new Set(["dispatch", ...agents.map((agent) => agent.id)]);
|
|
26
26
|
const selectedAppIds = Array.from(
|
|
27
27
|
new Set(
|
|
28
28
|
args.selectedAppIds
|
|
@@ -36,10 +36,6 @@ export default defineAction({
|
|
|
36
36
|
`Unknown app(s): ${unknown.join(", ")}. Use list-mcp-app-access to see available app IDs.`,
|
|
37
37
|
);
|
|
38
38
|
}
|
|
39
|
-
if (args.mode === "selected-apps" && selectedAppIds.length === 0) {
|
|
40
|
-
throw new Error("selected-apps mode requires at least one app ID.");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
39
|
await setDispatchMcpAppAccessSettings({
|
|
44
40
|
mode: args.mode,
|
|
45
41
|
selectedAppIds,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { ActionQueryError } from "./action-query-error";
|
|
7
|
+
|
|
8
|
+
vi.mock("@agent-native/core/client", () => ({
|
|
9
|
+
useT: () => (key: string) =>
|
|
10
|
+
({
|
|
11
|
+
"dispatch.pages.dataLoadFailed": "Couldn't load data",
|
|
12
|
+
"dispatch.pages.dataLoadFailedDescription":
|
|
13
|
+
"Dispatch couldn't load this data.",
|
|
14
|
+
"dispatch.pages.tryAgain": "Try again",
|
|
15
|
+
})[key] ?? key,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
describe("ActionQueryError", () => {
|
|
19
|
+
let container: HTMLDivElement;
|
|
20
|
+
let root: Root;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
24
|
+
container = document.createElement("div");
|
|
25
|
+
document.body.appendChild(container);
|
|
26
|
+
root = createRoot(container);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
act(() => root.unmount());
|
|
31
|
+
container.remove();
|
|
32
|
+
vi.unstubAllGlobals();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("shows a safe query error and retries on request", () => {
|
|
36
|
+
const onRetry = vi.fn();
|
|
37
|
+
act(() => {
|
|
38
|
+
root.render(
|
|
39
|
+
<ActionQueryError
|
|
40
|
+
error={new Error("Database unavailable")}
|
|
41
|
+
onRetry={onRetry}
|
|
42
|
+
/>,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(container.textContent).toContain("Couldn't load data");
|
|
47
|
+
expect(container.textContent).toContain(
|
|
48
|
+
"Dispatch couldn't load this data.",
|
|
49
|
+
);
|
|
50
|
+
expect(container.textContent).not.toContain("Database unavailable");
|
|
51
|
+
act(() => {
|
|
52
|
+
container.querySelector("button")?.click();
|
|
53
|
+
});
|
|
54
|
+
expect(onRetry).toHaveBeenCalledOnce();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useT } from "@agent-native/core/client";
|
|
2
|
+
import { IconAlertTriangle, IconRefresh } from "@tabler/icons-react";
|
|
3
|
+
|
|
4
|
+
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
|
5
|
+
import { Button } from "./ui/button";
|
|
6
|
+
|
|
7
|
+
interface ActionQueryErrorProps {
|
|
8
|
+
error?: unknown;
|
|
9
|
+
onRetry: () => unknown;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ActionQueryError({
|
|
14
|
+
onRetry,
|
|
15
|
+
className,
|
|
16
|
+
}: ActionQueryErrorProps) {
|
|
17
|
+
const t = useT();
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Alert variant="destructive" className={className}>
|
|
21
|
+
<IconAlertTriangle className="size-4" />
|
|
22
|
+
<AlertTitle>{t("dispatch.pages.dataLoadFailed")}</AlertTitle>
|
|
23
|
+
<AlertDescription className="flex flex-wrap items-center justify-between gap-3">
|
|
24
|
+
<span>{t("dispatch.pages.dataLoadFailedDescription")}</span>
|
|
25
|
+
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
|
26
|
+
<IconRefresh className="size-4" />
|
|
27
|
+
{t("dispatch.pages.tryAgain")}
|
|
28
|
+
</Button>
|
|
29
|
+
</AlertDescription>
|
|
30
|
+
</Alert>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { AppKeysPanel } from "./app-keys-popover";
|
|
7
|
+
|
|
8
|
+
const queryState = vi.hoisted(() => ({
|
|
9
|
+
refetchSecrets: vi.fn(),
|
|
10
|
+
refetchGrants: vi.fn(),
|
|
11
|
+
refetchAccess: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("@agent-native/core/client", () => ({
|
|
15
|
+
useT: () => (key: string) =>
|
|
16
|
+
({
|
|
17
|
+
"dispatch.pages.dataLoadFailed": "Couldn't load data",
|
|
18
|
+
"dispatch.pages.dataLoadFailedDescription":
|
|
19
|
+
"Dispatch couldn't load this data.",
|
|
20
|
+
"dispatch.pages.tryAgain": "Try again",
|
|
21
|
+
})[key] ?? key,
|
|
22
|
+
useActionQuery: (name: string) => {
|
|
23
|
+
const queries = {
|
|
24
|
+
"list-vault-secret-options": {
|
|
25
|
+
data: undefined,
|
|
26
|
+
isLoading: false,
|
|
27
|
+
error: new Error("Vault unavailable"),
|
|
28
|
+
refetch: queryState.refetchSecrets,
|
|
29
|
+
},
|
|
30
|
+
"list-vault-grants": {
|
|
31
|
+
data: [],
|
|
32
|
+
isLoading: false,
|
|
33
|
+
error: null,
|
|
34
|
+
refetch: queryState.refetchGrants,
|
|
35
|
+
},
|
|
36
|
+
"get-vault-access-settings": {
|
|
37
|
+
data: undefined,
|
|
38
|
+
isLoading: false,
|
|
39
|
+
error: null,
|
|
40
|
+
refetch: queryState.refetchAccess,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
return queries[name as keyof typeof queries];
|
|
44
|
+
},
|
|
45
|
+
useActionMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
describe("AppKeysPanel", () => {
|
|
49
|
+
let container: HTMLDivElement;
|
|
50
|
+
let root: Root;
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
54
|
+
vi.clearAllMocks();
|
|
55
|
+
container = document.createElement("div");
|
|
56
|
+
document.body.appendChild(container);
|
|
57
|
+
root = createRoot(container);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
act(() => root.unmount());
|
|
62
|
+
container.remove();
|
|
63
|
+
vi.unstubAllGlobals();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("shows a retryable error instead of an empty key list", () => {
|
|
67
|
+
act(() => {
|
|
68
|
+
root.render(<AppKeysPanel appId="mail" appName="Mail" />);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(container.textContent).toContain("Couldn't load data");
|
|
72
|
+
expect(container.textContent).toContain(
|
|
73
|
+
"Dispatch couldn't load this data.",
|
|
74
|
+
);
|
|
75
|
+
expect(container.textContent).not.toContain("Vault unavailable");
|
|
76
|
+
expect(container.textContent).not.toContain("No vault keys yet");
|
|
77
|
+
|
|
78
|
+
act(() => {
|
|
79
|
+
container.querySelector("button")?.click();
|
|
80
|
+
});
|
|
81
|
+
expect(queryState.refetchSecrets).toHaveBeenCalledOnce();
|
|
82
|
+
expect(queryState.refetchGrants).toHaveBeenCalledOnce();
|
|
83
|
+
expect(queryState.refetchAccess).toHaveBeenCalledOnce();
|
|
84
|
+
});
|
|
85
|
+
});
|