@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.
@@ -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
- 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]