@agent-native/dispatch 0.40.30 → 0.40.33
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/CHANGELOG.md +28 -25
- package/dist/actions/connect-external-agent.d.ts.map +1 -1
- package/dist/actions/connect-external-agent.js +2 -9
- package/dist/actions/connect-external-agent.js.map +1 -1
- package/dist/actions/list-connected-agents.js +7 -3
- package/dist/actions/list-connected-agents.js.map +1 -1
- package/dist/actions/list-dispatch-usage-metrics.js +1 -1
- package/dist/actions/list-dispatch-usage-metrics.js.map +1 -1
- package/dist/actions/list-workspace-connections.d.ts.map +1 -1
- package/dist/actions/list-workspace-connections.js +13 -18
- package/dist/actions/list-workspace-connections.js.map +1 -1
- package/dist/actions/provider-api-register.d.ts +8 -8
- package/dist/actions/provider-api-register.js +2 -14
- package/dist/actions/provider-api-register.js.map +1 -1
- package/dist/components/action-query-error.d.ts +2 -1
- package/dist/components/action-query-error.d.ts.map +1 -1
- package/dist/components/action-query-error.js +25 -2
- package/dist/components/action-query-error.js.map +1 -1
- package/dist/components/chat-home-apps.d.ts.map +1 -1
- package/dist/components/chat-home-apps.js +2 -2
- package/dist/components/chat-home-apps.js.map +1 -1
- package/dist/components/dispatch-control-plane.d.ts.map +1 -1
- package/dist/components/dispatch-control-plane.js +1 -1
- package/dist/components/dispatch-control-plane.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 +7 -0
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +4 -9
- package/dist/db/index.js.map +1 -1
- package/dist/routes/pages/$appId.d.ts.map +1 -1
- package/dist/routes/pages/$appId.js +1 -1
- package/dist/routes/pages/$appId.js.map +1 -1
- package/dist/routes/pages/apps.js +1 -1
- package/dist/routes/pages/apps.js.map +1 -1
- package/dist/server/lib/admin-config.d.ts.map +1 -1
- package/dist/server/lib/admin-config.js +3 -1
- package/dist/server/lib/admin-config.js.map +1 -1
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +124 -65
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/dist/server/lib/community-apps.js +1 -1
- package/dist/server/lib/community-apps.js.map +1 -1
- package/dist/server/lib/mcp-access-store.d.ts.map +1 -1
- package/dist/server/lib/mcp-access-store.js +2 -10
- package/dist/server/lib/mcp-access-store.js.map +1 -1
- package/dist/server/lib/thread-debug-store.d.ts.map +1 -1
- package/dist/server/lib/thread-debug-store.js +2 -6
- package/dist/server/lib/thread-debug-store.js.map +1 -1
- package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
- package/dist/server/lib/usage-metrics-store.js +2 -11
- package/dist/server/lib/usage-metrics-store.js.map +1 -1
- package/dist/server/lib/vault-store.d.ts.map +1 -1
- package/dist/server/lib/vault-store.js +2 -9
- package/dist/server/lib/vault-store.js.map +1 -1
- package/dist/server/lib/workspace-app-action-auth.d.ts.map +1 -1
- package/dist/server/lib/workspace-app-action-auth.js +7 -2
- package/dist/server/lib/workspace-app-action-auth.js.map +1 -1
- package/dist/server/plugins/agent-chat.js +1 -1
- package/dist/server/plugins/agent-chat.js.map +1 -1
- package/package.json +3 -3
- package/src/actions/connect-external-agent.spec.ts +25 -11
- package/src/actions/connect-external-agent.ts +2 -9
- package/src/actions/index.spec.ts +3 -0
- package/src/actions/list-connected-agents.spec.ts +17 -1
- package/src/actions/list-connected-agents.ts +7 -2
- package/src/actions/list-dispatch-usage-metrics.ts +1 -1
- package/src/actions/list-workspace-connections.spec.ts +14 -3
- package/src/actions/list-workspace-connections.ts +13 -17
- package/src/actions/provider-api-register.spec.ts +15 -9
- package/src/actions/provider-api-register.ts +5 -13
- package/src/components/action-query-error.spec.tsx +20 -0
- package/src/components/action-query-error.tsx +40 -0
- package/src/components/chat-home-apps.spec.tsx +32 -1
- package/src/components/chat-home-apps.tsx +6 -1
- package/src/components/dispatch-control-plane.tsx +1 -0
- package/src/components/layout/Layout.tsx +9 -0
- package/src/db/index.spec.ts +74 -0
- package/src/db/index.ts +4 -9
- package/src/routes/pages/$appId.tsx +1 -0
- package/src/routes/pages/apps.spec.tsx +26 -2
- package/src/routes/pages/apps.tsx +2 -0
- package/src/server/lib/admin-config.spec.ts +51 -0
- package/src/server/lib/admin-config.ts +2 -1
- package/src/server/lib/app-creation-store.spec.ts +308 -10
- package/src/server/lib/app-creation-store.ts +174 -70
- package/src/server/lib/community-apps.ts +1 -1
- package/src/server/lib/mcp-access-store.spec.ts +39 -12
- package/src/server/lib/mcp-access-store.ts +3 -10
- package/src/server/lib/thread-debug-store.spec.ts +64 -0
- package/src/server/lib/thread-debug-store.ts +2 -9
- package/src/server/lib/usage-metrics-store.spec.ts +64 -0
- package/src/server/lib/usage-metrics-store.ts +2 -17
- package/src/server/lib/vault-store.spec.ts +26 -9
- package/src/server/lib/vault-store.ts +3 -10
- package/src/server/lib/workspace-app-action-auth.spec.ts +37 -6
- package/src/server/lib/workspace-app-action-auth.ts +12 -2
- package/src/server/plugins/agent-chat.spec.ts +14 -0
- package/src/server/plugins/agent-chat.ts +1 -1
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
const mocks = vi.hoisted(() => ({
|
|
4
|
-
getDbExec: vi.fn(),
|
|
5
4
|
resourcePutIfAbsent: vi.fn(),
|
|
6
5
|
sharedResourceOwner: vi.fn(),
|
|
6
|
+
readOrgMemberRole: vi.fn(),
|
|
7
7
|
currentOrgId: vi.fn(),
|
|
8
8
|
currentOwnerEmail: vi.fn(),
|
|
9
9
|
}));
|
|
10
10
|
|
|
11
|
-
vi.mock("@agent-native/core/
|
|
12
|
-
|
|
11
|
+
vi.mock("@agent-native/core/server", () => ({
|
|
12
|
+
readOrgMemberRole: mocks.readOrgMemberRole,
|
|
13
13
|
}));
|
|
14
14
|
|
|
15
15
|
vi.mock("@agent-native/core/resources/store", () => ({
|
|
@@ -31,11 +31,9 @@ describe("connect-external-agent action", () => {
|
|
|
31
31
|
vi.clearAllMocks();
|
|
32
32
|
mocks.currentOrgId.mockReturnValue("org_1");
|
|
33
33
|
mocks.currentOwnerEmail.mockReturnValue("owner@example.com");
|
|
34
|
+
mocks.readOrgMemberRole.mockResolvedValue("admin");
|
|
34
35
|
mocks.sharedResourceOwner.mockReturnValue("shared-owner");
|
|
35
36
|
mocks.resourcePutIfAbsent.mockResolvedValue({ id: "resource_1" });
|
|
36
|
-
mocks.getDbExec.mockReturnValue({
|
|
37
|
-
execute: vi.fn().mockResolvedValue({ rows: [{ role: "owner" }] }),
|
|
38
|
-
});
|
|
39
37
|
});
|
|
40
38
|
|
|
41
39
|
it("rejects a malformed endpoint URL with a clean validation error instead of an unhandled throw", async () => {
|
|
@@ -115,16 +113,14 @@ describe("connect-external-agent action", () => {
|
|
|
115
113
|
}
|
|
116
114
|
|
|
117
115
|
expect(isActionContractError(caught)).toBe(true);
|
|
118
|
-
expect(mocks.
|
|
116
|
+
expect(mocks.readOrgMemberRole).not.toHaveBeenCalled();
|
|
119
117
|
expect(mocks.sharedResourceOwner).not.toHaveBeenCalled();
|
|
120
118
|
expect(mocks.resourcePutIfAbsent).not.toHaveBeenCalled();
|
|
121
119
|
});
|
|
122
120
|
|
|
123
121
|
it("requires an owner or admin actor for shared agents before writing", async () => {
|
|
124
122
|
mocks.currentOrgId.mockReturnValue("org_1");
|
|
125
|
-
mocks.
|
|
126
|
-
execute: vi.fn().mockResolvedValue({ rows: [{ role: "member" }] }),
|
|
127
|
-
});
|
|
123
|
+
mocks.readOrgMemberRole.mockResolvedValue("member");
|
|
128
124
|
|
|
129
125
|
let caught: unknown;
|
|
130
126
|
try {
|
|
@@ -154,7 +150,7 @@ describe("connect-external-agent action", () => {
|
|
|
154
150
|
|
|
155
151
|
expect(result.status).toBe("created");
|
|
156
152
|
expect(result.scope).toBe("personal");
|
|
157
|
-
expect(mocks.
|
|
153
|
+
expect(mocks.readOrgMemberRole).not.toHaveBeenCalled();
|
|
158
154
|
expect(mocks.resourcePutIfAbsent).toHaveBeenCalledWith(
|
|
159
155
|
"member@example.com",
|
|
160
156
|
"remote-agents/personal-agent.json",
|
|
@@ -173,6 +169,24 @@ describe("connect-external-agent action", () => {
|
|
|
173
169
|
expect(mocks.resourcePutIfAbsent).toHaveBeenCalled();
|
|
174
170
|
});
|
|
175
171
|
|
|
172
|
+
it("does not let an asserted owner register a shared agent without an admin role", async () => {
|
|
173
|
+
mocks.currentOrgId.mockReturnValue("org-1");
|
|
174
|
+
mocks.currentOwnerEmail.mockReturnValue("owner@example.com");
|
|
175
|
+
mocks.readOrgMemberRole.mockResolvedValue("member");
|
|
176
|
+
|
|
177
|
+
await expect(
|
|
178
|
+
action.run({
|
|
179
|
+
url: "https://agent.example.com",
|
|
180
|
+
scope: "shared",
|
|
181
|
+
} as never),
|
|
182
|
+
).rejects.toMatchObject({ statusCode: 403 });
|
|
183
|
+
expect(mocks.readOrgMemberRole).toHaveBeenCalledWith(
|
|
184
|
+
"org-1",
|
|
185
|
+
"owner@example.com",
|
|
186
|
+
);
|
|
187
|
+
expect(mocks.resourcePutIfAbsent).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
|
|
176
190
|
it("rejects a concurrent duplicate connect as a clean 409, not an overwrite", async () => {
|
|
177
191
|
mocks.resourcePutIfAbsent.mockResolvedValue(null);
|
|
178
192
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { defineAction, fail } from "@agent-native/core/action";
|
|
2
|
-
import { getDbExec } from "@agent-native/core/db";
|
|
3
2
|
import {
|
|
4
3
|
resourcePutIfAbsent,
|
|
5
4
|
sharedResourceOwner,
|
|
6
5
|
} from "@agent-native/core/resources/store";
|
|
6
|
+
import { readOrgMemberRole } from "@agent-native/core/server";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
|
|
9
9
|
import { normalizeConnectUrl } from "../lib/connect-apps.js";
|
|
@@ -30,14 +30,7 @@ async function assertCanManageSharedAgent() {
|
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
32
|
const actor = currentOwnerEmail().trim().toLowerCase();
|
|
33
|
-
const
|
|
34
|
-
sql: `SELECT role FROM org_members
|
|
35
|
-
WHERE org_id = ? AND LOWER(email) = ?
|
|
36
|
-
AND federation_removal_pending_at IS NULL
|
|
37
|
-
LIMIT 1`,
|
|
38
|
-
args: [orgId, actor],
|
|
39
|
-
});
|
|
40
|
-
const role = result.rows[0]?.role;
|
|
33
|
+
const role = await readOrgMemberRole(orgId, actor);
|
|
41
34
|
if (role !== "owner" && role !== "admin") {
|
|
42
35
|
fail("Only organization owners and admins can connect agents.", {
|
|
43
36
|
statusCode: 403,
|
|
@@ -94,5 +94,8 @@ describe("dispatch action registry", () => {
|
|
|
94
94
|
"workspaceAppCreationsByUserMonth",
|
|
95
95
|
);
|
|
96
96
|
expect(action.tool.description).toContain("app adoption metrics");
|
|
97
|
+
expect(action.tool.description).toContain(
|
|
98
|
+
"do not include customer/account/company usage or product data",
|
|
99
|
+
);
|
|
97
100
|
});
|
|
98
101
|
});
|
|
@@ -3,7 +3,9 @@ import { describe, expect, it, vi } from "vitest";
|
|
|
3
3
|
const mocks = vi.hoisted(() => ({
|
|
4
4
|
discoverAgents: vi.fn(),
|
|
5
5
|
getBuiltinAgents: vi.fn(),
|
|
6
|
-
getRequestUserEmail: vi.fn(
|
|
6
|
+
getRequestUserEmail: vi.fn<() => string | undefined>(
|
|
7
|
+
() => "owner@example.test",
|
|
8
|
+
),
|
|
7
9
|
resourceGet: vi.fn(),
|
|
8
10
|
resourceListAccessible: vi.fn(async (): Promise<unknown[]> => []),
|
|
9
11
|
parseRemoteAgentManifest: vi.fn(),
|
|
@@ -141,4 +143,18 @@ describe("list-connected-agents", () => {
|
|
|
141
143
|
expect(entry).toMatchObject({ id: "mail", source: "workspace" });
|
|
142
144
|
expect(entry).not.toHaveProperty("homeUrl");
|
|
143
145
|
});
|
|
146
|
+
|
|
147
|
+
it("returns a typed authentication error when the session has no user email", async () => {
|
|
148
|
+
mocks.discoverAgents.mockResolvedValue([]);
|
|
149
|
+
mocks.getBuiltinAgents.mockReturnValue([]);
|
|
150
|
+
mocks.loadWorkspaceAppsManifest.mockResolvedValue(null);
|
|
151
|
+
mocks.getRequestUserEmail.mockReturnValueOnce(undefined);
|
|
152
|
+
|
|
153
|
+
const { default: action } = await import("./list-connected-agents.js");
|
|
154
|
+
|
|
155
|
+
await expect(action.run({})).rejects.toMatchObject({
|
|
156
|
+
statusCode: 401,
|
|
157
|
+
errorCode: "dispatch_user_identity_missing",
|
|
158
|
+
});
|
|
159
|
+
});
|
|
144
160
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineAction } from "@agent-native/core/action";
|
|
1
|
+
import { defineAction, fail } from "@agent-native/core/action";
|
|
2
2
|
import {
|
|
3
3
|
REMOTE_AGENT_RESOURCE_PREFIXES,
|
|
4
4
|
parseRemoteAgentManifest,
|
|
@@ -45,7 +45,12 @@ export default defineAction({
|
|
|
45
45
|
builtins.map((agent) => [agent.id, agent.url]),
|
|
46
46
|
);
|
|
47
47
|
const ownerEmail = getRequestUserEmail();
|
|
48
|
-
if (!ownerEmail)
|
|
48
|
+
if (!ownerEmail) {
|
|
49
|
+
fail("Sign in to list connected agents.", {
|
|
50
|
+
errorCode: "dispatch_user_identity_missing",
|
|
51
|
+
statusCode: 401,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
49
54
|
const resources: Array<{ id: string; path: string; owner: string }> = [];
|
|
50
55
|
for (const prefix of [...REMOTE_AGENT_RESOURCE_PREFIXES].reverse()) {
|
|
51
56
|
resources.push(...(await resourceListAccessible(ownerEmail, prefix)));
|
|
@@ -5,7 +5,7 @@ import { listDispatchUsageMetrics } from "../server/lib/usage-metrics-store.js";
|
|
|
5
5
|
|
|
6
6
|
export default defineAction({
|
|
7
7
|
description:
|
|
8
|
-
"Get personal
|
|
8
|
+
"Get personal or workspace-member agent usage and spend, or aggregate app adoption metrics for Dispatch. Workspace results do not include customer/account/company usage or product data; use the relevant workspace app for those. Results may include active users, tracked app actions, app attribution, prompt previews, and recent activity. billing.unit is unknown when historical usage lacks engine metadata or mixes billing sources; monthlyByUser credits are null in that case. Workspace results also include monthlyByUser credit rows and workspaceAppCreationsByUserMonth rows from the shared token_usage and Dispatch audit tables. App scope is aggregate-only and returns daily/weekly active users plus tracked action counts for the selected app.",
|
|
9
9
|
schema: z.object({
|
|
10
10
|
sinceDays: z.coerce
|
|
11
11
|
.number()
|
|
@@ -10,6 +10,9 @@ const mocks = vi.hoisted(() => ({
|
|
|
10
10
|
hasWorkspaceProviderOAuthCredentials: vi.fn(),
|
|
11
11
|
isProviderApiId: vi.fn(() => false),
|
|
12
12
|
listProviderApiCatalog: vi.fn(() => []),
|
|
13
|
+
listWorkspaceApps: vi.fn(async () => [
|
|
14
|
+
{ id: "dispatch", name: "Dispatch", status: "ready" },
|
|
15
|
+
]),
|
|
13
16
|
listWorkspaceConnectionGrants: vi.fn(),
|
|
14
17
|
listWorkspaceConnectionProviders: vi.fn(),
|
|
15
18
|
listWorkspaceConnectionsForUser: vi.fn(),
|
|
@@ -54,9 +57,7 @@ vi.mock("@agent-native/core/workspace-connections", () => ({
|
|
|
54
57
|
vi.mock("@agent-native/dispatch/actions", () => ({
|
|
55
58
|
dispatchActions: {
|
|
56
59
|
"list-workspace-apps": {
|
|
57
|
-
run:
|
|
58
|
-
{ id: "dispatch", name: "Dispatch", status: "ready" },
|
|
59
|
-
]),
|
|
60
|
+
run: mocks.listWorkspaceApps,
|
|
60
61
|
},
|
|
61
62
|
},
|
|
62
63
|
}));
|
|
@@ -123,4 +124,14 @@ describe("list-workspace-connections", () => {
|
|
|
123
124
|
expect(result.providers.map((provider) => provider.id)).toEqual(["github"]);
|
|
124
125
|
expect(result.counts.providers).toBe(1);
|
|
125
126
|
});
|
|
127
|
+
|
|
128
|
+
it("preserves workspace app lookup failures instead of returning partial grants", async () => {
|
|
129
|
+
const error = Object.assign(new Error("Workspace registry unavailable"), {
|
|
130
|
+
statusCode: 503,
|
|
131
|
+
errorCode: "workspace_gateway_org_a2a_secret_unavailable",
|
|
132
|
+
});
|
|
133
|
+
mocks.listWorkspaceApps.mockRejectedValueOnce(error);
|
|
134
|
+
|
|
135
|
+
await expect(action.run({})).rejects.toBe(error);
|
|
136
|
+
});
|
|
126
137
|
});
|
|
@@ -79,23 +79,19 @@ async function listGrantApps(): Promise<GrantApp[]> {
|
|
|
79
79
|
const listWorkspaceApps = dispatchActions["list-workspace-apps"];
|
|
80
80
|
if (!listWorkspaceApps) return [{ id: "dispatch", label: "Dispatch" }];
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
: [{ id: "dispatch", label: "Dispatch" }];
|
|
96
|
-
} catch {
|
|
97
|
-
return [{ id: "dispatch", label: "Dispatch" }];
|
|
98
|
-
}
|
|
82
|
+
const apps = (await listWorkspaceApps.run({
|
|
83
|
+
includeAgentCards: false,
|
|
84
|
+
audience: "all",
|
|
85
|
+
} as any)) as WorkspaceApp[];
|
|
86
|
+
const grantApps = apps
|
|
87
|
+
.filter((app) => !app.archived && app.status !== "pending")
|
|
88
|
+
.map((app) => ({
|
|
89
|
+
id: app.id,
|
|
90
|
+
label: app.name || humanizeAppId(app.id),
|
|
91
|
+
}));
|
|
92
|
+
return grantApps.length > 0
|
|
93
|
+
? grantApps
|
|
94
|
+
: [{ id: "dispatch", label: "Dispatch" }];
|
|
99
95
|
}
|
|
100
96
|
|
|
101
97
|
export default defineAction({
|
|
@@ -13,7 +13,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
13
13
|
|
|
14
14
|
const mocks = vi.hoisted(() => ({
|
|
15
15
|
getCredentialContext: vi.fn(),
|
|
16
|
-
|
|
16
|
+
readOrgMemberRole: vi.fn(),
|
|
17
17
|
upsertCustomProvider: vi.fn(),
|
|
18
18
|
deleteCustomProvider: vi.fn(),
|
|
19
19
|
listCustomProviders: vi.fn(),
|
|
@@ -22,10 +22,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
22
22
|
|
|
23
23
|
vi.mock("@agent-native/core/server", () => ({
|
|
24
24
|
getCredentialContext: mocks.getCredentialContext,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
vi.mock("@agent-native/core/db", () => ({
|
|
28
|
-
getDbExec: () => ({ execute: mocks.execute }),
|
|
25
|
+
readOrgMemberRole: mocks.readOrgMemberRole,
|
|
29
26
|
}));
|
|
30
27
|
|
|
31
28
|
vi.mock("@agent-native/core/provider-api", async (importOriginal) => {
|
|
@@ -43,7 +40,7 @@ vi.mock("@agent-native/core/provider-api", async (importOriginal) => {
|
|
|
43
40
|
const action = (await import("./provider-api-register.js")).default;
|
|
44
41
|
|
|
45
42
|
function mockOrgRole(role: string | null): void {
|
|
46
|
-
mocks.
|
|
43
|
+
mocks.readOrgMemberRole.mockResolvedValue(role);
|
|
47
44
|
}
|
|
48
45
|
|
|
49
46
|
const baseUpsertInput = {
|
|
@@ -136,7 +133,7 @@ describe("provider-api-register org-scope authorization", () => {
|
|
|
136
133
|
"my-api",
|
|
137
134
|
null,
|
|
138
135
|
);
|
|
139
|
-
expect(mocks.
|
|
136
|
+
expect(mocks.readOrgMemberRole).not.toHaveBeenCalled();
|
|
140
137
|
});
|
|
141
138
|
|
|
142
139
|
it("allows an org-scope upsert/delete when the caller has no active org", async () => {
|
|
@@ -165,7 +162,7 @@ describe("provider-api-register org-scope authorization", () => {
|
|
|
165
162
|
"my-api",
|
|
166
163
|
"owner",
|
|
167
164
|
);
|
|
168
|
-
expect(mocks.
|
|
165
|
+
expect(mocks.readOrgMemberRole).not.toHaveBeenCalled();
|
|
169
166
|
});
|
|
170
167
|
|
|
171
168
|
it("does not gate org-scope list/get reads on org role", async () => {
|
|
@@ -181,6 +178,15 @@ describe("provider-api-register org-scope authorization", () => {
|
|
|
181
178
|
"org-a",
|
|
182
179
|
"my-api",
|
|
183
180
|
);
|
|
184
|
-
expect(mocks.
|
|
181
|
+
expect(mocks.readOrgMemberRole).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("rejects owner/admin writes when the shared resolver caps a delegated role", async () => {
|
|
185
|
+
mockOrgRole("member");
|
|
186
|
+
|
|
187
|
+
await expect(
|
|
188
|
+
action.run({ ...baseUpsertInput, scope: "org" }),
|
|
189
|
+
).rejects.toThrow(/owners and admins/i);
|
|
190
|
+
expect(mocks.upsertCustomProvider).not.toHaveBeenCalled();
|
|
185
191
|
});
|
|
186
192
|
});
|
|
@@ -1,23 +1,15 @@
|
|
|
1
|
-
import { getDbExec } from "@agent-native/core/db";
|
|
2
1
|
import { createCustomProviderRegistrationAction } from "@agent-native/core/provider-api/actions/custom-provider-registration";
|
|
3
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getCredentialContext,
|
|
4
|
+
readOrgMemberRole,
|
|
5
|
+
} from "@agent-native/core/server";
|
|
4
6
|
|
|
5
7
|
async function resolveCallerOrgRole(
|
|
6
8
|
orgId: string,
|
|
7
9
|
email: string,
|
|
8
10
|
): Promise<string | null> {
|
|
9
11
|
try {
|
|
10
|
-
|
|
11
|
-
const { rows } = await client.execute({
|
|
12
|
-
sql: `SELECT role FROM org_members
|
|
13
|
-
WHERE org_id = ? AND LOWER(email) = ?
|
|
14
|
-
AND federation_removal_pending_at IS NULL
|
|
15
|
-
LIMIT 1`,
|
|
16
|
-
args: [orgId, email.toLowerCase()],
|
|
17
|
-
});
|
|
18
|
-
if (rows.length === 0) return null;
|
|
19
|
-
const role = (rows[0] as { role?: unknown }).role;
|
|
20
|
-
return typeof role === "string" && role ? role : null;
|
|
12
|
+
return await readOrgMemberRole(orgId, email);
|
|
21
13
|
} catch {
|
|
22
14
|
return null;
|
|
23
15
|
}
|
|
@@ -53,4 +53,24 @@ describe("ActionQueryError", () => {
|
|
|
53
53
|
});
|
|
54
54
|
expect(onRetry).toHaveBeenCalledOnce();
|
|
55
55
|
});
|
|
56
|
+
|
|
57
|
+
it("shows the query and safe HTTP diagnostic without exposing the message", () => {
|
|
58
|
+
act(() => {
|
|
59
|
+
root.render(
|
|
60
|
+
<ActionQueryError
|
|
61
|
+
error={Object.assign(new Error("secret database detail"), {
|
|
62
|
+
status: 401,
|
|
63
|
+
errorCode: "workspace_gateway_rejected",
|
|
64
|
+
})}
|
|
65
|
+
queryName="list-workspace-apps"
|
|
66
|
+
onRetry={() => {}}
|
|
67
|
+
/>,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(container.textContent).toContain(
|
|
72
|
+
"list-workspace-apps · HTTP 401 · workspace_gateway_rejected",
|
|
73
|
+
);
|
|
74
|
+
expect(container.textContent).not.toContain("secret database detail");
|
|
75
|
+
});
|
|
56
76
|
});
|
|
@@ -8,13 +8,48 @@ interface ActionQueryErrorProps {
|
|
|
8
8
|
error?: unknown;
|
|
9
9
|
onRetry: () => unknown;
|
|
10
10
|
className?: string;
|
|
11
|
+
queryName?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function actionQueryDiagnostic(
|
|
15
|
+
error: unknown,
|
|
16
|
+
queryName: string | undefined,
|
|
17
|
+
): string | null {
|
|
18
|
+
const parts: string[] = [];
|
|
19
|
+
if (queryName) parts.push(queryName);
|
|
20
|
+
|
|
21
|
+
if (error && typeof error === "object") {
|
|
22
|
+
const details = error as Record<string, unknown>;
|
|
23
|
+
const status =
|
|
24
|
+
typeof details.statusCode === "number"
|
|
25
|
+
? details.statusCode
|
|
26
|
+
: details.status;
|
|
27
|
+
if (
|
|
28
|
+
typeof status === "number" &&
|
|
29
|
+
Number.isInteger(status) &&
|
|
30
|
+
status >= 100 &&
|
|
31
|
+
status <= 599
|
|
32
|
+
) {
|
|
33
|
+
parts.push(`HTTP ${status}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const code = details.errorCode ?? details.code;
|
|
37
|
+
if (typeof code === "string" && /^[a-zA-Z0-9_.-]{1,80}$/.test(code)) {
|
|
38
|
+
parts.push(code);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return parts.length > 0 ? parts.join(" · ") : null;
|
|
11
43
|
}
|
|
12
44
|
|
|
13
45
|
export function ActionQueryError({
|
|
46
|
+
error,
|
|
14
47
|
onRetry,
|
|
15
48
|
className,
|
|
49
|
+
queryName,
|
|
16
50
|
}: ActionQueryErrorProps) {
|
|
17
51
|
const t = useT();
|
|
52
|
+
const diagnostic = actionQueryDiagnostic(error, queryName);
|
|
18
53
|
|
|
19
54
|
return (
|
|
20
55
|
<Alert variant="destructive" className={className}>
|
|
@@ -22,6 +57,11 @@ export function ActionQueryError({
|
|
|
22
57
|
<AlertTitle>{t("dispatch.pages.dataLoadFailed")}</AlertTitle>
|
|
23
58
|
<AlertDescription className="flex flex-wrap items-center justify-between gap-3">
|
|
24
59
|
<span>{t("dispatch.pages.dataLoadFailedDescription")}</span>
|
|
60
|
+
{diagnostic ? (
|
|
61
|
+
<code className="w-full break-all text-xs text-muted-foreground">
|
|
62
|
+
{diagnostic}
|
|
63
|
+
</code>
|
|
64
|
+
) : null}
|
|
25
65
|
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
|
26
66
|
<IconRefresh className="size-4" />
|
|
27
67
|
{t("dispatch.pages.tryAgain")}
|
|
@@ -25,7 +25,11 @@ const launcherState = vi.hoisted(() => ({
|
|
|
25
25
|
},
|
|
26
26
|
],
|
|
27
27
|
isLoading: false,
|
|
28
|
-
error: undefined,
|
|
28
|
+
error: undefined as unknown,
|
|
29
|
+
errorQueryName: undefined as
|
|
30
|
+
| "list-workspace-apps"
|
|
31
|
+
| "list-connected-agents"
|
|
32
|
+
| undefined,
|
|
29
33
|
openApp: vi.fn(),
|
|
30
34
|
retry: vi.fn(),
|
|
31
35
|
},
|
|
@@ -89,6 +93,8 @@ describe("DispatchChatHomeApps", () => {
|
|
|
89
93
|
];
|
|
90
94
|
launcherState.value.openApp.mockReset();
|
|
91
95
|
launcherState.value.retry.mockReset();
|
|
96
|
+
launcherState.value.error = undefined;
|
|
97
|
+
launcherState.value.errorQueryName = undefined;
|
|
92
98
|
container = document.createElement("div");
|
|
93
99
|
document.body.appendChild(container);
|
|
94
100
|
root = createRoot(container);
|
|
@@ -194,4 +200,29 @@ describe("DispatchChatHomeApps", () => {
|
|
|
194
200
|
expect(container.textContent).toContain("Localized Calendar description");
|
|
195
201
|
expect(container.textContent).not.toContain("Default English text");
|
|
196
202
|
});
|
|
203
|
+
|
|
204
|
+
it.each([
|
|
205
|
+
["list-workspace-apps", "Workspace apps failed"],
|
|
206
|
+
["list-connected-agents", "Connected agents failed"],
|
|
207
|
+
] as const)("identifies the failed %s query", async (queryName, message) => {
|
|
208
|
+
launcherState.value.workspaceApps = [];
|
|
209
|
+
launcherState.value.error = Object.assign(new Error(message), {
|
|
210
|
+
statusCode: 503,
|
|
211
|
+
errorCode: "workspace_gateway_unavailable",
|
|
212
|
+
});
|
|
213
|
+
launcherState.value.errorQueryName = queryName;
|
|
214
|
+
|
|
215
|
+
await act(async () => {
|
|
216
|
+
root.render(
|
|
217
|
+
<MemoryRouter initialEntries={["/chat"]}>
|
|
218
|
+
<DispatchChatHomeApps />
|
|
219
|
+
</MemoryRouter>,
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(container.textContent).toContain(
|
|
224
|
+
`${queryName} · HTTP 503 · workspace_gateway_unavailable`,
|
|
225
|
+
);
|
|
226
|
+
expect(container.textContent).not.toContain(message);
|
|
227
|
+
});
|
|
197
228
|
});
|
|
@@ -48,7 +48,11 @@ export function DispatchChatHomeApps() {
|
|
|
48
48
|
if (launcher.error && launcher.workspaceApps.length === 0) {
|
|
49
49
|
return (
|
|
50
50
|
<div className="mx-auto w-full max-w-[1000px]">
|
|
51
|
-
<ActionQueryError
|
|
51
|
+
<ActionQueryError
|
|
52
|
+
error={launcher.error}
|
|
53
|
+
onRetry={launcher.retry}
|
|
54
|
+
queryName={launcher.errorQueryName}
|
|
55
|
+
/>
|
|
52
56
|
</div>
|
|
53
57
|
);
|
|
54
58
|
}
|
|
@@ -61,6 +65,7 @@ export function DispatchChatHomeApps() {
|
|
|
61
65
|
error={launcher.error}
|
|
62
66
|
onRetry={launcher.retry}
|
|
63
67
|
className="mb-3"
|
|
68
|
+
queryName={launcher.errorQueryName}
|
|
64
69
|
/>
|
|
65
70
|
) : null}
|
|
66
71
|
<section aria-label={t("dispatch.nav.apps")}>
|
|
@@ -360,6 +360,7 @@ export function DispatchControlPlane() {
|
|
|
360
360
|
<ActionQueryError
|
|
361
361
|
error={appsQuery.error}
|
|
362
362
|
onRetry={() => void appsQuery.refetch()}
|
|
363
|
+
queryName="list-workspace-apps"
|
|
363
364
|
/>
|
|
364
365
|
) : (
|
|
365
366
|
<AppsPanel apps={workspaceApps ?? []} isLoading={appsLoading} />
|
|
@@ -299,6 +299,7 @@ interface DispatchWorkspaceAppLauncher {
|
|
|
299
299
|
workspaceApps: readonly WorkspaceAppSummary[];
|
|
300
300
|
isLoading: boolean;
|
|
301
301
|
error?: unknown;
|
|
302
|
+
errorQueryName?: "list-workspace-apps" | "list-connected-agents";
|
|
302
303
|
openApp: (app: ChatFirstAppItem) => void;
|
|
303
304
|
retry: () => void;
|
|
304
305
|
}
|
|
@@ -1487,6 +1488,12 @@ function DispatchLayout({
|
|
|
1487
1488
|
const chatFirstAppsFailed = chatFirstAppsQuery.isError;
|
|
1488
1489
|
const chatFirstAppsError =
|
|
1489
1490
|
chatFirstAppsQuery.error ?? chatFirstConnectedAppsQuery.error;
|
|
1491
|
+
const chatFirstAppsErrorQueryName =
|
|
1492
|
+
chatFirstAppsQuery.error != null
|
|
1493
|
+
? "list-workspace-apps"
|
|
1494
|
+
: chatFirstConnectedAppsQuery.error != null
|
|
1495
|
+
? "list-connected-agents"
|
|
1496
|
+
: undefined;
|
|
1490
1497
|
const chatFirstResolvableApps = useMemo(
|
|
1491
1498
|
() =>
|
|
1492
1499
|
mergeChatFirstWorkspaceApps(
|
|
@@ -1574,6 +1581,7 @@ function DispatchLayout({
|
|
|
1574
1581
|
workspaceApps: chatFirstWorkspaceApps,
|
|
1575
1582
|
isLoading: chatFirstAppsLoading,
|
|
1576
1583
|
error: chatFirstAppsError,
|
|
1584
|
+
errorQueryName: chatFirstAppsErrorQueryName,
|
|
1577
1585
|
openApp: openChatFirstApp,
|
|
1578
1586
|
retry: () => {
|
|
1579
1587
|
void chatFirstAppsQuery.refetch();
|
|
@@ -1582,6 +1590,7 @@ function DispatchLayout({
|
|
|
1582
1590
|
}),
|
|
1583
1591
|
[
|
|
1584
1592
|
chatFirstAppsError,
|
|
1593
|
+
chatFirstAppsErrorQueryName,
|
|
1585
1594
|
chatFirstAppsLoading,
|
|
1586
1595
|
chatFirstAppsQuery.refetch,
|
|
1587
1596
|
chatFirstConnectedAppsQuery.refetch,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => {
|
|
4
|
+
let registeredResource: any;
|
|
5
|
+
return {
|
|
6
|
+
registerShareableResource: vi.fn((resource: any) => {
|
|
7
|
+
registeredResource = resource;
|
|
8
|
+
}),
|
|
9
|
+
readOrgMemberRole: vi.fn(),
|
|
10
|
+
getRegisteredResource: () => registeredResource,
|
|
11
|
+
};
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
vi.mock("@agent-native/core/db", () => ({
|
|
15
|
+
createGetDb: () => () => ({}),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("@agent-native/core/sharing", () => ({
|
|
19
|
+
registerShareableResource: mocks.registerShareableResource,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("@agent-native/core/server", () => ({
|
|
23
|
+
readOrgMemberRole: mocks.readOrgMemberRole,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
await import("./index.js");
|
|
27
|
+
|
|
28
|
+
describe("Dispatch workspace app share access", () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.clearAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const canManageAccess = () =>
|
|
34
|
+
mocks.getRegisteredResource().canManageAccess as (
|
|
35
|
+
resource: { orgId?: string },
|
|
36
|
+
ctx: { userEmail?: string; orgId?: string },
|
|
37
|
+
) => Promise<boolean>;
|
|
38
|
+
|
|
39
|
+
it("uses the shared request-scoped role resolver for an admin", async () => {
|
|
40
|
+
mocks.readOrgMemberRole.mockResolvedValue("admin");
|
|
41
|
+
|
|
42
|
+
await expect(
|
|
43
|
+
canManageAccess()(
|
|
44
|
+
{ orgId: "org-a" },
|
|
45
|
+
{ userEmail: "admin@example.com", orgId: "org-a" },
|
|
46
|
+
),
|
|
47
|
+
).resolves.toBe(true);
|
|
48
|
+
expect(mocks.readOrgMemberRole).toHaveBeenCalledWith(
|
|
49
|
+
"org-a",
|
|
50
|
+
"admin@example.com",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("denies org-member assertions whose stored owner role is capped to member", async () => {
|
|
55
|
+
mocks.readOrgMemberRole.mockResolvedValue("member");
|
|
56
|
+
|
|
57
|
+
await expect(
|
|
58
|
+
canManageAccess()(
|
|
59
|
+
{ orgId: "org-a" },
|
|
60
|
+
{ userEmail: "owner@example.com", orgId: "org-a" },
|
|
61
|
+
),
|
|
62
|
+
).resolves.toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("does not resolve a role for a resource outside the request org", async () => {
|
|
66
|
+
await expect(
|
|
67
|
+
canManageAccess()(
|
|
68
|
+
{ orgId: "org-b" },
|
|
69
|
+
{ userEmail: "member@example.com", orgId: "org-a" },
|
|
70
|
+
),
|
|
71
|
+
).resolves.toBe(false);
|
|
72
|
+
expect(mocks.readOrgMemberRole).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/db/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { createGetDb
|
|
1
|
+
import { createGetDb } from "@agent-native/core/db";
|
|
2
|
+
import { readOrgMemberRole } from "@agent-native/core/server";
|
|
2
3
|
import { registerShareableResource } from "@agent-native/core/sharing";
|
|
3
4
|
import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
|
|
4
5
|
|
|
@@ -24,14 +25,8 @@ registerShareableResource({
|
|
|
24
25
|
if (!ctx.userEmail || !ctx.orgId || resource?.orgId !== ctx.orgId) {
|
|
25
26
|
return false;
|
|
26
27
|
}
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
WHERE org_id = ? AND LOWER(email) = LOWER(?)
|
|
30
|
-
AND federation_removal_pending_at IS NULL
|
|
31
|
-
AND role IN ('owner', 'admin') LIMIT 1`,
|
|
32
|
-
args: [ctx.orgId, ctx.userEmail],
|
|
33
|
-
});
|
|
34
|
-
return result.rows.length > 0;
|
|
28
|
+
const role = await readOrgMemberRole(ctx.orgId, ctx.userEmail);
|
|
29
|
+
return role === "owner" || role === "admin";
|
|
35
30
|
},
|
|
36
31
|
});
|
|
37
32
|
|