@agent-native/dispatch 0.40.33 → 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.
@@ -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
- throw new ForbiddenError(
1024
- "The selected user is not available in this workspace.",
1025
- );
1059
+ failUserNotOrgMember(requestedUserEmail);
1026
1060
  }
1027
1061
  const memberEmails = selectedUserEmail
1028
1062
  ? [selectedUserEmail]