@agent-native/dispatch 0.40.34 → 0.40.37
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 +41 -19
- 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 +16 -16
- package/dist/routes/pages/overview.d.ts +2 -2
- 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 +91 -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,50 @@ 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(
|
|
177
|
+
"own customer or billing actions",
|
|
178
|
+
);
|
|
179
|
+
expect(mocks.isOrgMember).not.toHaveBeenCalled();
|
|
180
|
+
expect(mocks.getUsageSummary).not.toHaveBeenCalled();
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
it("treats a delegated owner as a member for another person's usage instead of reporting them outside the org", async () => {
|
|
185
|
+
mocks.currentOrgId.mockReturnValue("org-a");
|
|
186
|
+
mocks.currentOwnerEmail.mockReturnValue("owner@example.test");
|
|
187
|
+
mocks.delegatedOrgMember = true;
|
|
188
|
+
mocks.execute.mockResolvedValue({ rows: [{ role: "owner" }] });
|
|
189
|
+
|
|
190
|
+
const error = await listDispatchUsageMetrics({
|
|
191
|
+
sinceDays: 7,
|
|
192
|
+
scope: "workspace",
|
|
193
|
+
userEmail: "member@example.test",
|
|
194
|
+
}).catch((caught: unknown) => caught);
|
|
195
|
+
|
|
196
|
+
expect(error).toMatchObject({
|
|
197
|
+
errorCode: "usage_user_lookup_forbidden",
|
|
198
|
+
statusCode: 403,
|
|
199
|
+
});
|
|
200
|
+
expect(mocks.isOrgMember).not.toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
|
|
151
203
|
it("does not let a delegated org member read workspace usage as an owner", async () => {
|
|
152
204
|
mocks.currentOrgId.mockReturnValue("org-a");
|
|
153
205
|
mocks.currentOwnerEmail.mockReturnValue("member@example.test");
|
|
@@ -447,6 +499,45 @@ describe("listDispatchUsageMetrics", () => {
|
|
|
447
499
|
).toBe(true);
|
|
448
500
|
});
|
|
449
501
|
|
|
502
|
+
it("redirects a non-member userEmail to the app that owns customer data with a typed error", async () => {
|
|
503
|
+
mocks.currentOrgId.mockReturnValue("org-a");
|
|
504
|
+
mocks.currentOwnerEmail.mockReturnValue("owner@example.test");
|
|
505
|
+
mocks.getUsageSummary.mockResolvedValue(null);
|
|
506
|
+
mocks.listWorkspaceApps.mockResolvedValue([]);
|
|
507
|
+
mocks.execute.mockImplementation(async ({ sql }: { sql: string }) => {
|
|
508
|
+
if (sql.includes("SELECT role FROM org_members")) {
|
|
509
|
+
return { rows: [{ role: "owner" }] };
|
|
510
|
+
}
|
|
511
|
+
if (sql.includes("SELECT email, role, joined_at")) {
|
|
512
|
+
return {
|
|
513
|
+
rows: [
|
|
514
|
+
{ email: "owner@example.test", role: "owner", joined_at: null },
|
|
515
|
+
{ email: "member@example.test", role: "member", joined_at: null },
|
|
516
|
+
],
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
return { rows: [] };
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const error = await listDispatchUsageMetrics({
|
|
523
|
+
sinceDays: 7,
|
|
524
|
+
scope: "workspace",
|
|
525
|
+
userEmail: "customer@customer.example",
|
|
526
|
+
}).catch((caught: unknown) => caught);
|
|
527
|
+
|
|
528
|
+
expect(error).toMatchObject({
|
|
529
|
+
errorCode: "usage_user_not_org_member",
|
|
530
|
+
statusCode: 404,
|
|
531
|
+
});
|
|
532
|
+
expect((error as Error).message).toContain(
|
|
533
|
+
"customer@customer.example is not a member of this organization",
|
|
534
|
+
);
|
|
535
|
+
expect((error as Error).message).toContain(
|
|
536
|
+
"own customer or billing actions",
|
|
537
|
+
);
|
|
538
|
+
expect((error as Error).message).toContain("describe-workspace-apps");
|
|
539
|
+
});
|
|
540
|
+
|
|
450
541
|
it("lets an app owner inspect aggregate adoption for their own app", async () => {
|
|
451
542
|
const now = Date.now();
|
|
452
543
|
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, so list-dispatch-usage-metrics has no data on them. For a customer's or account's usage or credits, use this app's own customer or billing actions if it has any, or find the app that owns that data with describe-workspace-apps query="<what you need>" and delegate with call-agent.`,
|
|
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
|
+
`list-dispatch-usage-metrics cannot answer this. For a teammate in this organization, only owners and admins can look up their usage. For a customer or other external user, list-dispatch-usage-metrics has no data: use this app's own customer or billing actions if it has any, or find the app that owns that data with describe-workspace-apps query="<what you need>" and delegate with call-agent.`,
|
|
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]
|