@agent-native/dispatch 0.40.34 → 0.40.36
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 +29 -13
- package/dist/actions/list-dispatch-usage-metrics.js +2 -2
- package/dist/actions/list-dispatch-usage-metrics.js.map +1 -1
- package/dist/actions/provider-api-register.d.ts +18 -18
- package/dist/server/lib/community-apps.js +1 -1
- package/dist/server/lib/community-apps.js.map +1 -1
- package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
- package/dist/server/lib/mcp-gateway.js +21 -8
- package/dist/server/lib/mcp-gateway.js.map +1 -1
- package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
- package/dist/server/lib/usage-metrics-store.js +25 -1
- package/dist/server/lib/usage-metrics-store.js.map +1 -1
- package/package.json +3 -3
- package/src/actions/index.spec.ts +7 -3
- package/src/actions/list-dispatch-usage-metrics.ts +4 -2
- package/src/server/lib/community-apps.ts +1 -1
- package/src/server/lib/mcp-gateway.spec.ts +79 -0
- package/src/server/lib/mcp-gateway.ts +24 -6
- package/src/server/lib/usage-metrics-store.spec.ts +87 -0
- package/src/server/lib/usage-metrics-store.ts +37 -3
|
@@ -10,6 +10,9 @@ const mocks = vi.hoisted(() => ({
|
|
|
10
10
|
currentOrgId: vi.fn((): string | null => null),
|
|
11
11
|
currentOwnerEmail: vi.fn(() => "owner@example.test"),
|
|
12
12
|
delegatedOrgMember: false,
|
|
13
|
+
isOrgMember: vi.fn(async (_orgId: string, email: string) =>
|
|
14
|
+
email.toLowerCase().endsWith("@example.test"),
|
|
15
|
+
),
|
|
13
16
|
registerBuiltinEngines: vi.fn(),
|
|
14
17
|
dispatchConfig: {
|
|
15
18
|
adminEmails: [] as string[],
|
|
@@ -39,6 +42,11 @@ vi.mock("@agent-native/core/settings", () => ({
|
|
|
39
42
|
getSetting: (...args: any[]) => mocks.getSetting(...args),
|
|
40
43
|
}));
|
|
41
44
|
|
|
45
|
+
vi.mock("@agent-native/core/org", () => ({
|
|
46
|
+
isOrgMember: (orgId: string, email: string) =>
|
|
47
|
+
mocks.isOrgMember(orgId, email),
|
|
48
|
+
}));
|
|
49
|
+
|
|
42
50
|
vi.mock("@agent-native/core/server", () => ({
|
|
43
51
|
getAppConfig: () => ({ dispatch: mocks.dispatchConfig }),
|
|
44
52
|
getRequestContext: () =>
|
|
@@ -148,6 +156,48 @@ describe("listDispatchUsageMetrics", () => {
|
|
|
148
156
|
expect(mocks.listWorkspaceApps).not.toHaveBeenCalled();
|
|
149
157
|
});
|
|
150
158
|
|
|
159
|
+
it.each(["customer@customer.example", "other@example.test"])(
|
|
160
|
+
"gives a non-admin the same lookup error whether or not %s is a member",
|
|
161
|
+
async (userEmail) => {
|
|
162
|
+
mocks.currentOrgId.mockReturnValue("org-a");
|
|
163
|
+
mocks.currentOwnerEmail.mockReturnValue("member@example.test");
|
|
164
|
+
mocks.execute.mockResolvedValue({ rows: [{ role: "member" }] });
|
|
165
|
+
|
|
166
|
+
const error = await listDispatchUsageMetrics({
|
|
167
|
+
sinceDays: 7,
|
|
168
|
+
scope: "workspace",
|
|
169
|
+
userEmail,
|
|
170
|
+
}).catch((caught: unknown) => caught);
|
|
171
|
+
|
|
172
|
+
expect(error).toMatchObject({
|
|
173
|
+
errorCode: "usage_user_lookup_forbidden",
|
|
174
|
+
statusCode: 403,
|
|
175
|
+
});
|
|
176
|
+
expect((error as Error).message).toContain("<relevant-apps>");
|
|
177
|
+
expect(mocks.isOrgMember).not.toHaveBeenCalled();
|
|
178
|
+
expect(mocks.getUsageSummary).not.toHaveBeenCalled();
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
it("treats a delegated owner as a member for another person's usage instead of reporting them outside the org", async () => {
|
|
183
|
+
mocks.currentOrgId.mockReturnValue("org-a");
|
|
184
|
+
mocks.currentOwnerEmail.mockReturnValue("owner@example.test");
|
|
185
|
+
mocks.delegatedOrgMember = true;
|
|
186
|
+
mocks.execute.mockResolvedValue({ rows: [{ role: "owner" }] });
|
|
187
|
+
|
|
188
|
+
const error = await listDispatchUsageMetrics({
|
|
189
|
+
sinceDays: 7,
|
|
190
|
+
scope: "workspace",
|
|
191
|
+
userEmail: "member@example.test",
|
|
192
|
+
}).catch((caught: unknown) => caught);
|
|
193
|
+
|
|
194
|
+
expect(error).toMatchObject({
|
|
195
|
+
errorCode: "usage_user_lookup_forbidden",
|
|
196
|
+
statusCode: 403,
|
|
197
|
+
});
|
|
198
|
+
expect(mocks.isOrgMember).not.toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
|
|
151
201
|
it("does not let a delegated org member read workspace usage as an owner", async () => {
|
|
152
202
|
mocks.currentOrgId.mockReturnValue("org-a");
|
|
153
203
|
mocks.currentOwnerEmail.mockReturnValue("member@example.test");
|
|
@@ -447,6 +497,43 @@ describe("listDispatchUsageMetrics", () => {
|
|
|
447
497
|
).toBe(true);
|
|
448
498
|
});
|
|
449
499
|
|
|
500
|
+
it("redirects a non-member userEmail to the app that owns customer data with a typed error", async () => {
|
|
501
|
+
mocks.currentOrgId.mockReturnValue("org-a");
|
|
502
|
+
mocks.currentOwnerEmail.mockReturnValue("owner@example.test");
|
|
503
|
+
mocks.getUsageSummary.mockResolvedValue(null);
|
|
504
|
+
mocks.listWorkspaceApps.mockResolvedValue([]);
|
|
505
|
+
mocks.execute.mockImplementation(async ({ sql }: { sql: string }) => {
|
|
506
|
+
if (sql.includes("SELECT role FROM org_members")) {
|
|
507
|
+
return { rows: [{ role: "owner" }] };
|
|
508
|
+
}
|
|
509
|
+
if (sql.includes("SELECT email, role, joined_at")) {
|
|
510
|
+
return {
|
|
511
|
+
rows: [
|
|
512
|
+
{ email: "owner@example.test", role: "owner", joined_at: null },
|
|
513
|
+
{ email: "member@example.test", role: "member", joined_at: null },
|
|
514
|
+
],
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
return { rows: [] };
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const error = await listDispatchUsageMetrics({
|
|
521
|
+
sinceDays: 7,
|
|
522
|
+
scope: "workspace",
|
|
523
|
+
userEmail: "customer@customer.example",
|
|
524
|
+
}).catch((caught: unknown) => caught);
|
|
525
|
+
|
|
526
|
+
expect(error).toMatchObject({
|
|
527
|
+
errorCode: "usage_user_not_org_member",
|
|
528
|
+
statusCode: 404,
|
|
529
|
+
});
|
|
530
|
+
expect((error as Error).message).toContain(
|
|
531
|
+
"customer@customer.example is not a member of this organization",
|
|
532
|
+
);
|
|
533
|
+
expect((error as Error).message).toContain("<relevant-apps>");
|
|
534
|
+
expect((error as Error).message).toContain("describe-workspace-apps");
|
|
535
|
+
});
|
|
536
|
+
|
|
450
537
|
it("lets an app owner inspect aggregate adoption for their own app", async () => {
|
|
451
538
|
const now = Date.now();
|
|
452
539
|
mocks.currentOrgId.mockReturnValue("org-a");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fail } from "@agent-native/core/action";
|
|
1
2
|
import {
|
|
2
3
|
detectEngineFromEnv,
|
|
3
4
|
detectEngineFromUserSecrets,
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
registerBuiltinEngines,
|
|
9
10
|
} from "@agent-native/core/agent/engine";
|
|
10
11
|
import { getDbExec } from "@agent-native/core/db";
|
|
12
|
+
import { isOrgMember } from "@agent-native/core/org";
|
|
11
13
|
import { readOrgMemberRole } from "@agent-native/core/server";
|
|
12
14
|
import { ForbiddenError } from "@agent-native/core/sharing";
|
|
13
15
|
import {
|
|
@@ -952,6 +954,13 @@ async function assertCanViewMetrics(viewScope: UsageMetricsScope): Promise<{
|
|
|
952
954
|
);
|
|
953
955
|
}
|
|
954
956
|
|
|
957
|
+
function failUserNotOrgMember(email: string): never {
|
|
958
|
+
fail(
|
|
959
|
+
`${email} is not a member of this organization. list-dispatch-usage-metrics only covers this workspace's own agent usage; usage, credits, or activity for customers, accounts, or companies belongs to the app that owns that data (see <relevant-apps> or run describe-workspace-apps with a query).`,
|
|
960
|
+
{ errorCode: "usage_user_not_org_member", statusCode: 404 },
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
|
|
955
964
|
export async function listDispatchUsageMetrics(input: {
|
|
956
965
|
sinceDays?: number;
|
|
957
966
|
scope?: UsageMetricsScope;
|
|
@@ -960,6 +969,33 @@ export async function listDispatchUsageMetrics(input: {
|
|
|
960
969
|
}): Promise<DispatchUsageMetrics> {
|
|
961
970
|
const viewScope: UsageMetricsScope =
|
|
962
971
|
input.scope === "me" ? "me" : input.scope === "app" ? "app" : "workspace";
|
|
972
|
+
// Another person's usage: unauthorized callers get one answer whether or not
|
|
973
|
+
// the email is a member, so the lookup cannot probe the roster; authorized
|
|
974
|
+
// callers asking about an outside email are pointed at the owning app.
|
|
975
|
+
const requestedEmail =
|
|
976
|
+
viewScope === "workspace" ? input.userEmail?.trim() || null : null;
|
|
977
|
+
const callerEmail = currentOwnerEmail();
|
|
978
|
+
const callerOrgId = currentOrgId();
|
|
979
|
+
if (
|
|
980
|
+
requestedEmail &&
|
|
981
|
+
callerOrgId &&
|
|
982
|
+
requestedEmail.toLowerCase() !== callerEmail.toLowerCase()
|
|
983
|
+
) {
|
|
984
|
+
const callerRole = await readOrgMemberRole(callerOrgId, callerEmail);
|
|
985
|
+
if (
|
|
986
|
+
!isDispatchEnvironmentAdmin(callerEmail) &&
|
|
987
|
+
callerRole !== "owner" &&
|
|
988
|
+
callerRole !== "admin"
|
|
989
|
+
) {
|
|
990
|
+
fail(
|
|
991
|
+
"Only organization owners and admins can look up another person's usage with list-dispatch-usage-metrics. Usage, credits, or activity for customers, accounts, or companies belongs to the app that owns that data (see <relevant-apps> or run describe-workspace-apps with a query).",
|
|
992
|
+
{ errorCode: "usage_user_lookup_forbidden", statusCode: 403 },
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
if (!(await isOrgMember(callerOrgId, requestedEmail))) {
|
|
996
|
+
failUserNotOrgMember(requestedEmail);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
963
999
|
const { viewerEmail, orgId, role } = await assertCanViewMetrics(
|
|
964
1000
|
viewScope === "app" ? "me" : viewScope,
|
|
965
1001
|
);
|
|
@@ -1020,9 +1056,7 @@ export async function listDispatchUsageMetrics(input: {
|
|
|
1020
1056
|
)?.email ?? null)
|
|
1021
1057
|
: null;
|
|
1022
1058
|
if (viewScope === "workspace" && requestedUserEmail && !selectedUserEmail) {
|
|
1023
|
-
|
|
1024
|
-
"The selected user is not available in this workspace.",
|
|
1025
|
-
);
|
|
1059
|
+
failUserNotOrgMember(requestedUserEmail);
|
|
1026
1060
|
}
|
|
1027
1061
|
const memberEmails = selectedUserEmail
|
|
1028
1062
|
? [selectedUserEmail]
|