@agent-native/dispatch 0.40.31 → 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 +19 -12
- 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-dispatch-usage-metrics.js +1 -1
- package/dist/actions/list-dispatch-usage-metrics.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/db/index.d.ts.map +1 -1
- package/dist/db/index.js +4 -9
- package/dist/db/index.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 +2 -11
- 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-dispatch-usage-metrics.ts +1 -1
- package/src/actions/provider-api-register.spec.ts +15 -9
- package/src/actions/provider-api-register.ts +5 -13
- package/src/db/index.spec.ts +74 -0
- package/src/db/index.ts +4 -9
- 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 +27 -5
- package/src/server/lib/app-creation-store.ts +2 -10
- 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
|
});
|
|
@@ -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()
|
|
@@ -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
|
}
|
|
@@ -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
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const getAppConfigMock = vi.hoisted(() => vi.fn());
|
|
4
|
+
|
|
5
|
+
vi.mock("@agent-native/core/server", async (importOriginal) => ({
|
|
6
|
+
...(await importOriginal<typeof import("@agent-native/core/server")>()),
|
|
7
|
+
getAppConfig: () => getAppConfigMock(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { runWithRequestContext } from "@agent-native/core/server";
|
|
11
|
+
|
|
12
|
+
import { isDispatchEnvironmentAdmin } from "./admin-config.js";
|
|
13
|
+
|
|
14
|
+
describe("isDispatchEnvironmentAdmin", () => {
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.clearAllMocks();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("recognizes configured environment administrators outside delegated requests", () => {
|
|
20
|
+
getAppConfigMock.mockReturnValue({
|
|
21
|
+
dispatch: {
|
|
22
|
+
adminEmails: ["platform@example.test"],
|
|
23
|
+
workspaceOwnerEmails: [],
|
|
24
|
+
defaultOwnerEmail: null,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(isDispatchEnvironmentAdmin("PLATFORM@example.test")).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("does not turn an org-member assertion for an environment admin into global access", async () => {
|
|
32
|
+
getAppConfigMock.mockReturnValue({
|
|
33
|
+
dispatch: {
|
|
34
|
+
adminEmails: ["platform@example.test"],
|
|
35
|
+
workspaceOwnerEmails: [],
|
|
36
|
+
defaultOwnerEmail: null,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await runWithRequestContext(
|
|
41
|
+
{
|
|
42
|
+
userEmail: "platform@example.test",
|
|
43
|
+
orgId: "org-1",
|
|
44
|
+
a2aIdentityAssurance: "org-member",
|
|
45
|
+
},
|
|
46
|
+
() => {
|
|
47
|
+
expect(isDispatchEnvironmentAdmin("platform@example.test")).toBe(false);
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { getAppConfig } from "@agent-native/core/server";
|
|
1
|
+
import { getAppConfig, getRequestContext } from "@agent-native/core/server";
|
|
2
2
|
|
|
3
3
|
export function isDispatchEnvironmentAdmin(email: string): boolean {
|
|
4
4
|
const normalized = email.trim().toLowerCase();
|
|
5
5
|
if (!normalized) return false;
|
|
6
|
+
if (getRequestContext()?.a2aIdentityAssurance === "org-member") return false;
|
|
6
7
|
const config = getAppConfig().dispatch;
|
|
7
8
|
return [
|
|
8
9
|
...config.adminEmails,
|
|
@@ -79,6 +79,7 @@ const mocks = vi.hoisted(() => {
|
|
|
79
79
|
role: "viewer",
|
|
80
80
|
resource: {},
|
|
81
81
|
})),
|
|
82
|
+
readOrgMemberRole: vi.fn(async () => state.orgRole),
|
|
82
83
|
getDbExec: vi.fn(() => ({
|
|
83
84
|
execute: vi.fn(async (statement: unknown) => {
|
|
84
85
|
const sql =
|
|
@@ -216,6 +217,7 @@ vi.mock("@agent-native/core/server", async (importOriginal) => {
|
|
|
216
217
|
await importOriginal<typeof import("@agent-native/core/server")>();
|
|
217
218
|
return {
|
|
218
219
|
...actual,
|
|
220
|
+
readOrgMemberRole: (...args: any[]) => mocks.readOrgMemberRole(...args),
|
|
219
221
|
resolveBuilderCredentialsDetailed: (...args: any[]) =>
|
|
220
222
|
mocks.resolveBuilderCredentialsDetailed(...args),
|
|
221
223
|
createBuilderProject: (...args: any[]) =>
|
|
@@ -261,6 +263,8 @@ afterEach(() => {
|
|
|
261
263
|
},
|
|
262
264
|
);
|
|
263
265
|
mocks.state.orgRole = "admin";
|
|
266
|
+
mocks.readOrgMemberRole.mockReset();
|
|
267
|
+
mocks.readOrgMemberRole.mockImplementation(async () => mocks.state.orgRole);
|
|
264
268
|
mocks.state.workspaceApps = [];
|
|
265
269
|
orgAppsDirectoryCache.clear();
|
|
266
270
|
orgAppsDirectoryAccessCache.clear();
|
|
@@ -2324,11 +2328,9 @@ describe("listWorkspaceApps", () => {
|
|
|
2324
2328
|
it("reports owner-role lookup failures as unavailable instead of unauthorized", async () => {
|
|
2325
2329
|
stubNoPendingContext();
|
|
2326
2330
|
stubManifest([{ id: "todo", name: "Todo", path: "/todo" }]);
|
|
2327
|
-
mocks.
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
}),
|
|
2331
|
-
} as any);
|
|
2331
|
+
mocks.readOrgMemberRole.mockRejectedValue(
|
|
2332
|
+
new Error("database unavailable"),
|
|
2333
|
+
);
|
|
2332
2334
|
|
|
2333
2335
|
await expect(
|
|
2334
2336
|
runWithRequestContext(
|
|
@@ -3203,4 +3205,24 @@ describe("setAppCreationSettings", () => {
|
|
|
3203
3205
|
expect(mocks.writeAppSecret).not.toHaveBeenCalled();
|
|
3204
3206
|
expect(mocks.deleteAppSecret).not.toHaveBeenCalled();
|
|
3205
3207
|
});
|
|
3208
|
+
|
|
3209
|
+
it("caps a delegated owner before changing the shared Builder project", async () => {
|
|
3210
|
+
mocks.readOrgMemberRole.mockResolvedValueOnce("member");
|
|
3211
|
+
|
|
3212
|
+
await expect(
|
|
3213
|
+
runWithRequestContext(
|
|
3214
|
+
{
|
|
3215
|
+
userEmail: "dev@example.test",
|
|
3216
|
+
orgId,
|
|
3217
|
+
a2aIdentityAssurance: "org-member",
|
|
3218
|
+
},
|
|
3219
|
+
() => setAppCreationSettings({ builderProjectId: projectId }),
|
|
3220
|
+
),
|
|
3221
|
+
).rejects.toMatchObject({ statusCode: 403 });
|
|
3222
|
+
expect(mocks.readOrgMemberRole).toHaveBeenCalledWith(
|
|
3223
|
+
orgId,
|
|
3224
|
+
"dev@example.test",
|
|
3225
|
+
);
|
|
3226
|
+
expect(mocks.putSetting).not.toHaveBeenCalled();
|
|
3227
|
+
});
|
|
3206
3228
|
});
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
inferWorkspaceAppRootHomePath,
|
|
32
32
|
isIntegrationCallerRequest,
|
|
33
33
|
readConfiguredWorkspaceAppHomePath,
|
|
34
|
+
readOrgMemberRole,
|
|
34
35
|
resolveAppRuntimeUrl,
|
|
35
36
|
resolveVercelDeploymentProtectionHeaders,
|
|
36
37
|
normalizeBuilderAgentAttachments,
|
|
@@ -2827,18 +2828,9 @@ function isSyntheticIntegrationOwner(ownerEmail: string): boolean {
|
|
|
2827
2828
|
|
|
2828
2829
|
async function requestOwnerRole(): Promise<string | null> {
|
|
2829
2830
|
const orgId = currentOrgId();
|
|
2830
|
-
const ownerEmail = currentOwnerEmail();
|
|
2831
2831
|
if (!orgId) return null;
|
|
2832
2832
|
try {
|
|
2833
|
-
|
|
2834
|
-
sql: `SELECT role FROM org_members
|
|
2835
|
-
WHERE org_id = ? AND LOWER(email) = ?
|
|
2836
|
-
AND federation_removal_pending_at IS NULL
|
|
2837
|
-
LIMIT 1`,
|
|
2838
|
-
args: [orgId, ownerEmail.toLowerCase()],
|
|
2839
|
-
});
|
|
2840
|
-
const role = (rows[0] as any)?.role;
|
|
2841
|
-
return typeof role === "string" ? role : null;
|
|
2833
|
+
return await readOrgMemberRole(orgId, currentOwnerEmail());
|
|
2842
2834
|
} catch (error) {
|
|
2843
2835
|
throw new AppCreationOwnerRoleUnavailableError(error);
|
|
2844
2836
|
}
|
|
@@ -95,7 +95,7 @@ export const CURATED_COMMUNITY_APP_SOURCES: readonly CuratedCommunityAppSource[]
|
|
|
95
95
|
},
|
|
96
96
|
{
|
|
97
97
|
path: "package.json",
|
|
98
|
-
blobSha: "
|
|
98
|
+
blobSha: "de917f7c794eceef303bb6f5a434ece39a6ab6fd",
|
|
99
99
|
},
|
|
100
100
|
{
|
|
101
101
|
path: "server/db/index.ts",
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
const mocks = vi.hoisted(() => ({
|
|
4
|
-
|
|
4
|
+
readOrgMemberRole: vi.fn(),
|
|
5
5
|
getOrgSetting: vi.fn(),
|
|
6
6
|
getUserSetting: vi.fn(),
|
|
7
7
|
putOrgSetting: vi.fn(),
|
|
8
8
|
putUserSetting: vi.fn(),
|
|
9
9
|
}));
|
|
10
10
|
|
|
11
|
-
vi.mock("@agent-native/core/
|
|
12
|
-
|
|
11
|
+
vi.mock("@agent-native/core/server", async (importOriginal) => ({
|
|
12
|
+
...(await importOriginal<typeof import("@agent-native/core/server")>()),
|
|
13
|
+
readOrgMemberRole: (...args: unknown[]) => mocks.readOrgMemberRole(...args),
|
|
13
14
|
}));
|
|
14
15
|
|
|
15
16
|
vi.mock("@agent-native/core/settings", () => ({
|
|
@@ -29,7 +30,7 @@ import {
|
|
|
29
30
|
|
|
30
31
|
beforeEach(() => {
|
|
31
32
|
vi.clearAllMocks();
|
|
32
|
-
mocks.
|
|
33
|
+
mocks.readOrgMemberRole.mockResolvedValue("owner");
|
|
33
34
|
});
|
|
34
35
|
|
|
35
36
|
describe("normalizeMcpAppAccessSettings", () => {
|
|
@@ -63,7 +64,7 @@ describe("setDispatchMcpAppAccessSettings", () => {
|
|
|
63
64
|
it.each(["owner", "admin"])(
|
|
64
65
|
"lets an organization %s change the app grants",
|
|
65
66
|
async (role) => {
|
|
66
|
-
mocks.
|
|
67
|
+
mocks.readOrgMemberRole.mockResolvedValue(role);
|
|
67
68
|
|
|
68
69
|
const result = await runWithRequestContext(
|
|
69
70
|
{ userEmail: "manager@example.test", orgId: "org-1" },
|
|
@@ -74,10 +75,10 @@ describe("setDispatchMcpAppAccessSettings", () => {
|
|
|
74
75
|
}),
|
|
75
76
|
);
|
|
76
77
|
|
|
77
|
-
expect(mocks.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
expect(mocks.readOrgMemberRole).toHaveBeenCalledWith(
|
|
79
|
+
"org-1",
|
|
80
|
+
"manager@example.test",
|
|
81
|
+
);
|
|
81
82
|
expect(mocks.putOrgSetting).toHaveBeenCalledWith(
|
|
82
83
|
"org-1",
|
|
83
84
|
"dispatch-mcp-app-access",
|
|
@@ -92,7 +93,7 @@ describe("setDispatchMcpAppAccessSettings", () => {
|
|
|
92
93
|
);
|
|
93
94
|
|
|
94
95
|
it("rejects an organization member without changing grants", async () => {
|
|
95
|
-
mocks.
|
|
96
|
+
mocks.readOrgMemberRole.mockResolvedValue("member");
|
|
96
97
|
|
|
97
98
|
await expect(
|
|
98
99
|
runWithRequestContext(
|
|
@@ -109,8 +110,34 @@ describe("setDispatchMcpAppAccessSettings", () => {
|
|
|
109
110
|
expect(mocks.putOrgSetting).not.toHaveBeenCalled();
|
|
110
111
|
});
|
|
111
112
|
|
|
113
|
+
it("caps a delegated owner before changing organization app grants", async () => {
|
|
114
|
+
mocks.readOrgMemberRole.mockResolvedValue("member");
|
|
115
|
+
|
|
116
|
+
await expect(
|
|
117
|
+
runWithRequestContext(
|
|
118
|
+
{
|
|
119
|
+
userEmail: "owner@example.test",
|
|
120
|
+
orgId: "org-1",
|
|
121
|
+
a2aIdentityAssurance: "org-member",
|
|
122
|
+
},
|
|
123
|
+
() =>
|
|
124
|
+
setDispatchMcpAppAccessSettings({
|
|
125
|
+
mode: "selected-apps",
|
|
126
|
+
selectedAppIds: ["analytics"],
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
129
|
+
).rejects.toMatchObject({ statusCode: 403 });
|
|
130
|
+
expect(mocks.readOrgMemberRole).toHaveBeenCalledWith(
|
|
131
|
+
"org-1",
|
|
132
|
+
"owner@example.test",
|
|
133
|
+
);
|
|
134
|
+
expect(mocks.putOrgSetting).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
112
137
|
it("fails closed when organization membership cannot be verified", async () => {
|
|
113
|
-
mocks.
|
|
138
|
+
mocks.readOrgMemberRole.mockRejectedValue(
|
|
139
|
+
new Error("org lookup unavailable"),
|
|
140
|
+
);
|
|
114
141
|
|
|
115
142
|
await expect(
|
|
116
143
|
runWithRequestContext(
|
|
@@ -132,7 +159,7 @@ describe("setDispatchMcpAppAccessSettings", () => {
|
|
|
132
159
|
}),
|
|
133
160
|
);
|
|
134
161
|
|
|
135
|
-
expect(mocks.
|
|
162
|
+
expect(mocks.readOrgMemberRole).not.toHaveBeenCalled();
|
|
136
163
|
expect(mocks.putUserSetting).toHaveBeenCalledWith(
|
|
137
164
|
"solo@example.test",
|
|
138
165
|
"dispatch-mcp-app-access",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { getDbExec } from "@agent-native/core/db";
|
|
2
1
|
import {
|
|
3
2
|
getRequestOrgId,
|
|
4
3
|
getRequestUserEmail,
|
|
4
|
+
readOrgMemberRole,
|
|
5
5
|
} from "@agent-native/core/server";
|
|
6
6
|
import {
|
|
7
7
|
getOrgSetting,
|
|
@@ -78,16 +78,9 @@ function currentAccessScope(): AccessScope {
|
|
|
78
78
|
async function assertCanManageMcpAppAccess(scope: AccessScope): Promise<void> {
|
|
79
79
|
if (scope.kind === "user") return;
|
|
80
80
|
|
|
81
|
-
let role:
|
|
81
|
+
let role: string | null = null;
|
|
82
82
|
try {
|
|
83
|
-
|
|
84
|
-
sql: `SELECT role FROM org_members
|
|
85
|
-
WHERE org_id = ? AND LOWER(email) = ?
|
|
86
|
-
AND federation_removal_pending_at IS NULL
|
|
87
|
-
LIMIT 1`,
|
|
88
|
-
args: [scope.id, scope.actor.toLowerCase()],
|
|
89
|
-
});
|
|
90
|
-
role = rows[0]?.role;
|
|
83
|
+
role = await readOrgMemberRole(scope.id, scope.actor);
|
|
91
84
|
} catch {
|
|
92
85
|
// Fail closed when org membership cannot be verified.
|
|
93
86
|
}
|