@agent-native/dispatch 0.40.30 → 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.
Files changed (100) hide show
  1. package/CHANGELOG.md +28 -25
  2. package/dist/actions/connect-external-agent.d.ts.map +1 -1
  3. package/dist/actions/connect-external-agent.js +2 -9
  4. package/dist/actions/connect-external-agent.js.map +1 -1
  5. package/dist/actions/list-connected-agents.js +7 -3
  6. package/dist/actions/list-connected-agents.js.map +1 -1
  7. package/dist/actions/list-dispatch-usage-metrics.js +1 -1
  8. package/dist/actions/list-dispatch-usage-metrics.js.map +1 -1
  9. package/dist/actions/list-workspace-connections.d.ts.map +1 -1
  10. package/dist/actions/list-workspace-connections.js +13 -18
  11. package/dist/actions/list-workspace-connections.js.map +1 -1
  12. package/dist/actions/provider-api-register.d.ts +8 -8
  13. package/dist/actions/provider-api-register.js +2 -14
  14. package/dist/actions/provider-api-register.js.map +1 -1
  15. package/dist/components/action-query-error.d.ts +2 -1
  16. package/dist/components/action-query-error.d.ts.map +1 -1
  17. package/dist/components/action-query-error.js +25 -2
  18. package/dist/components/action-query-error.js.map +1 -1
  19. package/dist/components/chat-home-apps.d.ts.map +1 -1
  20. package/dist/components/chat-home-apps.js +2 -2
  21. package/dist/components/chat-home-apps.js.map +1 -1
  22. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  23. package/dist/components/dispatch-control-plane.js +1 -1
  24. package/dist/components/dispatch-control-plane.js.map +1 -1
  25. package/dist/components/layout/Layout.d.ts +1 -0
  26. package/dist/components/layout/Layout.d.ts.map +1 -1
  27. package/dist/components/layout/Layout.js +7 -0
  28. package/dist/components/layout/Layout.js.map +1 -1
  29. package/dist/db/index.d.ts.map +1 -1
  30. package/dist/db/index.js +4 -9
  31. package/dist/db/index.js.map +1 -1
  32. package/dist/routes/pages/$appId.d.ts.map +1 -1
  33. package/dist/routes/pages/$appId.js +1 -1
  34. package/dist/routes/pages/$appId.js.map +1 -1
  35. package/dist/routes/pages/apps.js +1 -1
  36. package/dist/routes/pages/apps.js.map +1 -1
  37. package/dist/server/lib/admin-config.d.ts.map +1 -1
  38. package/dist/server/lib/admin-config.js +3 -1
  39. package/dist/server/lib/admin-config.js.map +1 -1
  40. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  41. package/dist/server/lib/app-creation-store.js +124 -65
  42. package/dist/server/lib/app-creation-store.js.map +1 -1
  43. package/dist/server/lib/community-apps.js +1 -1
  44. package/dist/server/lib/community-apps.js.map +1 -1
  45. package/dist/server/lib/mcp-access-store.d.ts.map +1 -1
  46. package/dist/server/lib/mcp-access-store.js +2 -10
  47. package/dist/server/lib/mcp-access-store.js.map +1 -1
  48. package/dist/server/lib/thread-debug-store.d.ts.map +1 -1
  49. package/dist/server/lib/thread-debug-store.js +2 -6
  50. package/dist/server/lib/thread-debug-store.js.map +1 -1
  51. package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
  52. package/dist/server/lib/usage-metrics-store.js +2 -11
  53. package/dist/server/lib/usage-metrics-store.js.map +1 -1
  54. package/dist/server/lib/vault-store.d.ts.map +1 -1
  55. package/dist/server/lib/vault-store.js +2 -9
  56. package/dist/server/lib/vault-store.js.map +1 -1
  57. package/dist/server/lib/workspace-app-action-auth.d.ts.map +1 -1
  58. package/dist/server/lib/workspace-app-action-auth.js +7 -2
  59. package/dist/server/lib/workspace-app-action-auth.js.map +1 -1
  60. package/dist/server/plugins/agent-chat.js +1 -1
  61. package/dist/server/plugins/agent-chat.js.map +1 -1
  62. package/package.json +3 -3
  63. package/src/actions/connect-external-agent.spec.ts +25 -11
  64. package/src/actions/connect-external-agent.ts +2 -9
  65. package/src/actions/index.spec.ts +3 -0
  66. package/src/actions/list-connected-agents.spec.ts +17 -1
  67. package/src/actions/list-connected-agents.ts +7 -2
  68. package/src/actions/list-dispatch-usage-metrics.ts +1 -1
  69. package/src/actions/list-workspace-connections.spec.ts +14 -3
  70. package/src/actions/list-workspace-connections.ts +13 -17
  71. package/src/actions/provider-api-register.spec.ts +15 -9
  72. package/src/actions/provider-api-register.ts +5 -13
  73. package/src/components/action-query-error.spec.tsx +20 -0
  74. package/src/components/action-query-error.tsx +40 -0
  75. package/src/components/chat-home-apps.spec.tsx +32 -1
  76. package/src/components/chat-home-apps.tsx +6 -1
  77. package/src/components/dispatch-control-plane.tsx +1 -0
  78. package/src/components/layout/Layout.tsx +9 -0
  79. package/src/db/index.spec.ts +74 -0
  80. package/src/db/index.ts +4 -9
  81. package/src/routes/pages/$appId.tsx +1 -0
  82. package/src/routes/pages/apps.spec.tsx +26 -2
  83. package/src/routes/pages/apps.tsx +2 -0
  84. package/src/server/lib/admin-config.spec.ts +51 -0
  85. package/src/server/lib/admin-config.ts +2 -1
  86. package/src/server/lib/app-creation-store.spec.ts +308 -10
  87. package/src/server/lib/app-creation-store.ts +174 -70
  88. package/src/server/lib/community-apps.ts +1 -1
  89. package/src/server/lib/mcp-access-store.spec.ts +39 -12
  90. package/src/server/lib/mcp-access-store.ts +3 -10
  91. package/src/server/lib/thread-debug-store.spec.ts +64 -0
  92. package/src/server/lib/thread-debug-store.ts +2 -9
  93. package/src/server/lib/usage-metrics-store.spec.ts +64 -0
  94. package/src/server/lib/usage-metrics-store.ts +2 -17
  95. package/src/server/lib/vault-store.spec.ts +26 -9
  96. package/src/server/lib/vault-store.ts +3 -10
  97. package/src/server/lib/workspace-app-action-auth.spec.ts +37 -6
  98. package/src/server/lib/workspace-app-action-auth.ts +12 -2
  99. package/src/server/plugins/agent-chat.spec.ts +14 -0
  100. package/src/server/plugins/agent-chat.ts +1 -1
@@ -3,12 +3,20 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
- import { canonicalA2AAudience, signA2AToken } from "@agent-native/core/a2a";
7
- import { isActionContractError } from "@agent-native/core/action";
6
+ import {
7
+ canonicalA2AAudience,
8
+ getGlobalA2ASecret,
9
+ signA2AToken,
10
+ } from "@agent-native/core/a2a";
11
+ import {
12
+ ActionContractError,
13
+ isActionContractError,
14
+ } from "@agent-native/core/action";
8
15
  import { describeErrorWithCauses } from "@agent-native/core/agent/engine/error-detail";
9
16
  import { getDbExec } from "@agent-native/core/db";
10
17
  import { and, eq, inArray } from "@agent-native/core/db/schema";
11
18
  import {
19
+ getOrgA2ASecret,
12
20
  getOrgDomain,
13
21
  isWorkspaceAppAccessAllowed,
14
22
  workspaceApps,
@@ -23,6 +31,7 @@ import {
23
31
  inferWorkspaceAppRootHomePath,
24
32
  isIntegrationCallerRequest,
25
33
  readConfiguredWorkspaceAppHomePath,
34
+ readOrgMemberRole,
26
35
  resolveAppRuntimeUrl,
27
36
  resolveVercelDeploymentProtectionHeaders,
28
37
  normalizeBuilderAgentAttachments,
@@ -152,17 +161,15 @@ function requireWorkspaceAppOrgId(): string {
152
161
  return orgId;
153
162
  }
154
163
 
155
- class WorkspaceAppsGatewayAuthorizationError extends Error {
164
+ class WorkspaceAppsGatewayAuthorizationError extends ActionContractError {
156
165
  constructor(
157
- statusCode: 401 | 403,
166
+ statusCode: 401 | 403 | 503,
167
+ errorCode = "workspace_gateway_rejected",
158
168
  message = `Workspace apps gateway rejected the request with HTTP ${statusCode}.`,
159
169
  ) {
160
- super(message);
170
+ super(message, { statusCode, errorCode });
161
171
  this.name = "WorkspaceAppsGatewayAuthorizationError";
162
- this.statusCode = statusCode;
163
172
  }
164
-
165
- statusCode: 401 | 403;
166
173
  }
167
174
 
168
175
  function warnWorkspaceAppsGatewayDenial(
@@ -871,6 +878,21 @@ function parseWorkspaceAppsManifest(parsed: any): WorkspaceAppSummary[] | null {
871
878
  return apps.length ? apps : null;
872
879
  }
873
880
 
881
+ function parseWorkspaceAppsGatewayResponse(
882
+ parsed: unknown,
883
+ ): WorkspaceAppSummary[] | null {
884
+ const rawApps =
885
+ parsed && typeof parsed === "object" && !Array.isArray(parsed)
886
+ ? (parsed as { apps?: unknown }).apps
887
+ : parsed;
888
+ if (!Array.isArray(rawApps)) return null;
889
+ if (rawApps.length === 0) return [];
890
+ const apps = parseWorkspaceAppsManifest(parsed);
891
+ if (!apps || apps.length !== rawApps.length) return null;
892
+ const ids = new Set(apps.map((app) => app.id.toLowerCase()));
893
+ return ids.size === apps.length ? apps : null;
894
+ }
895
+
874
896
  function parseDateMs(value: string | null | undefined): number | null {
875
897
  if (!value) return null;
876
898
  const ms = Date.parse(value);
@@ -1863,37 +1885,89 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
1863
1885
  }
1864
1886
 
1865
1887
  const requestContext = getRequestContext();
1888
+ const isLocalGateway = isLocalWorkspaceGateway(baseUrl);
1889
+ const requestOrigin = requestContext?.requestOrigin;
1890
+ const isSameOriginGateway = requestOrigin
1891
+ ? (() => {
1892
+ try {
1893
+ return new URL(requestOrigin).origin === baseUrl.origin;
1894
+ } catch {
1895
+ // coercion-ok: an invalid request origin cannot prove a self-fetch.
1896
+ return false;
1897
+ }
1898
+ })()
1899
+ : false;
1900
+ if (isSameOriginGateway && !isLocalGateway) return null;
1901
+
1866
1902
  const authHeaders: Record<string, string> = {};
1867
1903
  if (requestContext?.userEmail) {
1868
- const orgDomain = requestContext.orgId
1869
- ? (await getOrgDomain(requestContext.orgId))?.trim().toLowerCase()
1870
- : undefined;
1904
+ let orgDomain: string | undefined;
1905
+ let orgSecret: string | undefined;
1906
+ if (requestContext.orgId) {
1907
+ try {
1908
+ orgDomain =
1909
+ (await getOrgDomain(requestContext.orgId))?.trim().toLowerCase() ||
1910
+ undefined;
1911
+ } catch {
1912
+ throw new WorkspaceAppsGatewayAuthorizationError(
1913
+ 503,
1914
+ "workspace_gateway_org_domain_unavailable",
1915
+ "Workspace apps gateway organization domain could not be read.",
1916
+ );
1917
+ }
1918
+ }
1871
1919
  if (requestContext.orgId && !orgDomain) {
1872
1920
  throw new WorkspaceAppsGatewayAuthorizationError(
1873
1921
  401,
1922
+ "workspace_gateway_org_domain_missing",
1874
1923
  "Workspace apps gateway cannot authenticate without a resolved organization domain.",
1875
1924
  );
1876
1925
  }
1877
- try {
1878
- const token = await signA2AToken(
1879
- requestContext.userEmail,
1880
- orgDomain,
1881
- undefined,
1882
- {
1883
- expiresIn: "1m",
1884
- preferGlobalSecret: true,
1885
- audience: canonicalA2AAudience(
1886
- baseUrl.toString(),
1887
- getConfiguredAppBasePath() || undefined,
1888
- ),
1889
- },
1926
+ if (requestContext.orgId) {
1927
+ try {
1928
+ orgSecret =
1929
+ (await getOrgA2ASecret(requestContext.orgId))?.trim() || undefined;
1930
+ } catch {
1931
+ throw new WorkspaceAppsGatewayAuthorizationError(
1932
+ 503,
1933
+ "workspace_gateway_org_a2a_secret_unavailable",
1934
+ "Workspace apps gateway organization A2A secret could not be read.",
1935
+ );
1936
+ }
1937
+ }
1938
+ const globalSecret = getGlobalA2ASecret();
1939
+ const allowLocalUnauthenticatedRequest =
1940
+ isLocalGateway && !orgSecret && !globalSecret;
1941
+ if (!orgSecret && !globalSecret && !allowLocalUnauthenticatedRequest) {
1942
+ throw new WorkspaceAppsGatewayAuthorizationError(
1943
+ 503,
1944
+ "workspace_gateway_a2a_secret_missing",
1945
+ "Workspace apps gateway cannot authenticate because no A2A secret is configured.",
1890
1946
  );
1891
- authHeaders.Authorization = `Bearer ${token}`;
1892
- } catch {
1893
- // coercion-ok: absent signing credentials keep local unauthenticated
1894
- // gateway discovery available and make hosted discovery fail closed.
1895
- // Keep the unauthenticated local-dev gateway path available. A hosted
1896
- // gateway will fail closed below when its action route needs identity.
1947
+ }
1948
+ if (!allowLocalUnauthenticatedRequest) {
1949
+ try {
1950
+ const token = await signA2AToken(
1951
+ requestContext.userEmail,
1952
+ orgDomain,
1953
+ orgSecret,
1954
+ {
1955
+ expiresIn: "1m",
1956
+ preferGlobalSecret: false,
1957
+ audience: canonicalA2AAudience(
1958
+ baseUrl.toString(),
1959
+ getConfiguredAppBasePath() || undefined,
1960
+ ),
1961
+ },
1962
+ );
1963
+ authHeaders.Authorization = `Bearer ${token}`;
1964
+ } catch {
1965
+ throw new WorkspaceAppsGatewayAuthorizationError(
1966
+ 503,
1967
+ "workspace_gateway_token_signing_failed",
1968
+ "Workspace apps gateway could not sign the authenticated request.",
1969
+ );
1970
+ }
1897
1971
  }
1898
1972
  }
1899
1973
 
@@ -1923,9 +1997,34 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
1923
1997
  const protectedRedirect = protectionHeaders["x-vercel-protection-bypass"]
1924
1998
  ? { redirect: "manual" as const }
1925
1999
  : {};
2000
+ const readAppsResponse = async (response: Response) => {
2001
+ let payload: unknown;
2002
+ try {
2003
+ payload = await response.json();
2004
+ } catch {
2005
+ throw new ActionContractError(
2006
+ "Workspace apps gateway returned an unreadable response.",
2007
+ {
2008
+ statusCode: 503,
2009
+ errorCode: "workspace_gateway_invalid_response",
2010
+ },
2011
+ );
2012
+ }
2013
+ const apps = parseWorkspaceAppsGatewayResponse(payload);
2014
+ if (!apps) {
2015
+ throw new ActionContractError(
2016
+ "Workspace apps gateway returned an invalid response.",
2017
+ {
2018
+ statusCode: 503,
2019
+ errorCode: "workspace_gateway_invalid_response",
2020
+ },
2021
+ );
2022
+ }
2023
+ return { apps };
2024
+ };
1926
2025
 
1927
2026
  try {
1928
- if (isLocalWorkspaceGateway(baseUrl)) {
2027
+ if (isLocalGateway) {
1929
2028
  const localResponse = await fetch(
1930
2029
  gatewayUrl(WORKSPACE_APPS_GATEWAY_PATH),
1931
2030
  {
@@ -1935,29 +2034,25 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
1935
2034
  },
1936
2035
  );
1937
2036
  if (localResponse.status === 401 || localResponse.status === 403) {
1938
- throw new WorkspaceAppsGatewayAuthorizationError(localResponse.status);
2037
+ throw new WorkspaceAppsGatewayAuthorizationError(
2038
+ localResponse.status,
2039
+ "workspace_gateway_rejected",
2040
+ );
1939
2041
  }
1940
- if (localResponse.ok) {
1941
- const apps = parseWorkspaceAppsManifest(
1942
- // coercion-ok: malformed gateway JSON is an unavailable registry and
1943
- // must fall through to the local manifest sources.
1944
- await localResponse.json().catch(() => null),
2042
+ if (localResponse.status !== 404 && localResponse.status !== 200) {
2043
+ throw new ActionContractError(
2044
+ `Workspace apps gateway returned HTTP ${localResponse.status}.`,
2045
+ {
2046
+ statusCode: 503,
2047
+ errorCode: `workspace_gateway_http_${localResponse.status}`,
2048
+ },
1945
2049
  );
1946
- return apps ? { apps } : null;
2050
+ }
2051
+ if (localResponse.status === 200) {
2052
+ return await readAppsResponse(localResponse);
1947
2053
  }
1948
2054
  }
1949
2055
 
1950
- const requestOrigin = requestContext?.requestOrigin;
1951
- const isSameOriginGateway = requestOrigin
1952
- ? (() => {
1953
- try {
1954
- return new URL(requestOrigin).origin === baseUrl.origin;
1955
- } catch {
1956
- // coercion-ok: an invalid request origin cannot prove a self-fetch.
1957
- return false;
1958
- }
1959
- })()
1960
- : false;
1961
2056
  if (isSameOriginGateway || !authHeaders.Authorization) return null;
1962
2057
  const actionUrl = gatewayUrl(WORKSPACE_APPS_ACTION_PATH);
1963
2058
  actionUrl.searchParams.set("includeAgentCards", "false");
@@ -1968,18 +2063,31 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
1968
2063
  signal: controller.signal,
1969
2064
  });
1970
2065
  if (actionResponse.status === 401 || actionResponse.status === 403) {
1971
- throw new WorkspaceAppsGatewayAuthorizationError(actionResponse.status);
2066
+ throw new WorkspaceAppsGatewayAuthorizationError(
2067
+ actionResponse.status,
2068
+ "workspace_gateway_rejected",
2069
+ );
1972
2070
  }
1973
- if (!actionResponse.ok) return null;
1974
- const apps = parseWorkspaceAppsManifest(
1975
- // coercion-ok: malformed gateway JSON is an unavailable registry and
1976
- // must fall through to the local manifest sources.
1977
- await actionResponse.json().catch(() => null),
1978
- );
1979
- return apps ? { apps } : null;
2071
+ if (actionResponse.status === 404) return null;
2072
+ if (actionResponse.status !== 200) {
2073
+ throw new ActionContractError(
2074
+ `Workspace apps gateway returned HTTP ${actionResponse.status}.`,
2075
+ {
2076
+ statusCode: 503,
2077
+ errorCode: `workspace_gateway_http_${actionResponse.status}`,
2078
+ },
2079
+ );
2080
+ }
2081
+ return await readAppsResponse(actionResponse);
1980
2082
  } catch (error) {
1981
- if (error instanceof WorkspaceAppsGatewayAuthorizationError) throw error;
1982
- return null;
2083
+ if (error instanceof ActionContractError) throw error;
2084
+ throw new ActionContractError(
2085
+ "Workspace apps gateway could not be reached.",
2086
+ {
2087
+ statusCode: 503,
2088
+ errorCode: "workspace_gateway_unavailable",
2089
+ },
2090
+ );
1983
2091
  } finally {
1984
2092
  clearTimeout(timeout);
1985
2093
  }
@@ -2260,6 +2368,9 @@ async function listWorkspaceAppsWithPrincipal(
2260
2368
  if (gatewayApps) {
2261
2369
  return finalize(gatewayApps.apps);
2262
2370
  }
2371
+ if (gatewayDenial && gatewayDenial.statusCode >= 500) {
2372
+ throw gatewayDenial;
2373
+ }
2263
2374
  const unverified = gatewayDenial !== null;
2264
2375
 
2265
2376
  const workspaceRoot = findWorkspaceRoot();
@@ -2284,7 +2395,9 @@ async function listWorkspaceAppsWithPrincipal(
2284
2395
  // means the registry will not show this reader its apps, so they get the
2285
2396
  // deployment's own list instead, and none of it is recorded. (A manifest
2286
2397
  // answers earlier for both, with the warning above.)
2287
- if (gatewayDenial?.statusCode === 401) throw gatewayDenial;
2398
+ if (gatewayDenial && gatewayDenial.statusCode !== 403) {
2399
+ throw gatewayDenial;
2400
+ }
2288
2401
  warnWorkspaceAppsGatewayDenial(gatewayDenial, "deployment's own app list");
2289
2402
 
2290
2403
  if (!workspaceRoot) {
@@ -2715,18 +2828,9 @@ function isSyntheticIntegrationOwner(ownerEmail: string): boolean {
2715
2828
 
2716
2829
  async function requestOwnerRole(): Promise<string | null> {
2717
2830
  const orgId = currentOrgId();
2718
- const ownerEmail = currentOwnerEmail();
2719
2831
  if (!orgId) return null;
2720
2832
  try {
2721
- const { rows } = await getDbExec().execute({
2722
- sql: `SELECT role FROM org_members
2723
- WHERE org_id = ? AND LOWER(email) = ?
2724
- AND federation_removal_pending_at IS NULL
2725
- LIMIT 1`,
2726
- args: [orgId, ownerEmail.toLowerCase()],
2727
- });
2728
- const role = (rows[0] as any)?.role;
2729
- return typeof role === "string" ? role : null;
2833
+ return await readOrgMemberRole(orgId, currentOwnerEmail());
2730
2834
  } catch (error) {
2731
2835
  throw new AppCreationOwnerRoleUnavailableError(error);
2732
2836
  }
@@ -95,7 +95,7 @@ export const CURATED_COMMUNITY_APP_SOURCES: readonly CuratedCommunityAppSource[]
95
95
  },
96
96
  {
97
97
  path: "package.json",
98
- blobSha: "c7803d346368f927621329b2acbf5f6cc744dc18",
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
- execute: vi.fn(),
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/db", () => ({
12
- getDbExec: () => ({ execute: mocks.execute }),
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.execute.mockResolvedValue({ rows: [] });
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.execute.mockResolvedValue({ rows: [{ role }] });
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.execute).toHaveBeenCalledWith({
78
- sql: expect.stringContaining("FROM org_members"),
79
- args: ["org-1", "manager@example.test"],
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.execute.mockResolvedValue({ rows: [{ role: "member" }] });
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.execute.mockRejectedValue(new Error("org lookup unavailable"));
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.execute).not.toHaveBeenCalled();
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: unknown = null;
81
+ let role: string | null = null;
82
82
  try {
83
- const { rows } = await getDbExec().execute({
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
  }
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
6
6
  createDbExec: vi.fn(),
7
7
  orgId: null as string | null,
8
8
  ownerEmail: "owner@example.com",
9
+ delegatedOrgMember: false,
9
10
  dispatchConfig: {
10
11
  adminEmails: [] as string[],
11
12
  workspaceOwnerEmails: [] as string[],
@@ -20,10 +21,29 @@ vi.mock("@agent-native/core/db", () => ({
20
21
  }));
21
22
 
22
23
  vi.mock("@agent-native/core/server", () => ({
24
+ getRequestContext: () =>
25
+ mocks.delegatedOrgMember
26
+ ? {
27
+ a2aIdentityAssurance: "org-member",
28
+ userEmail: mocks.ownerEmail,
29
+ orgId: mocks.orgId,
30
+ }
31
+ : undefined,
23
32
  getAppConfig: () => ({
24
33
  dispatch: mocks.dispatchConfig,
25
34
  observability: { superOrgId: mocks.superOrgId },
26
35
  }),
36
+ readOrgMemberRole: async (orgId: string, email: string) => {
37
+ const result = await mocks.currentExecute({
38
+ sql: "SELECT role FROM org_members",
39
+ args: [orgId, email.toLowerCase()],
40
+ });
41
+ const role = result.rows[0]?.role;
42
+ if (mocks.delegatedOrgMember && (role === "owner" || role === "admin")) {
43
+ return "member";
44
+ }
45
+ return typeof role === "string" ? role : null;
46
+ },
27
47
  }));
28
48
 
29
49
  vi.mock("./dispatch-store.js", () => ({
@@ -114,6 +134,7 @@ describe("thread-debug-store", () => {
114
134
  beforeEach(() => {
115
135
  mocks.orgId = null;
116
136
  mocks.ownerEmail = "owner@example.com";
137
+ mocks.delegatedOrgMember = false;
117
138
  mocks.dispatchConfig.adminEmails = [];
118
139
  mocks.dispatchConfig.workspaceOwnerEmails = [];
119
140
  mocks.dispatchConfig.defaultOwnerEmail = undefined;
@@ -458,6 +479,27 @@ describe("thread-debug-store", () => {
458
479
  expect(result.thread.ownerEmail).toBe("outsider@example.com");
459
480
  });
460
481
 
482
+ it("scopes a delegated owner assertion to its own transcripts", async () => {
483
+ mocks.orgId = "org-1";
484
+ mocks.ownerEmail = "owner@example.com";
485
+ mocks.delegatedOrgMember = true;
486
+ mocks.currentExecute.mockImplementation(async ({ sql, args }) => {
487
+ if (sql.includes("SELECT role FROM org_members")) {
488
+ return { rows: [{ role: "owner" }] };
489
+ }
490
+ if (sql.includes("FROM chat_threads")) {
491
+ expect(sql).toContain("owner_email = ?");
492
+ expect(args).toContain("owner@example.com");
493
+ return { rows: [] };
494
+ }
495
+ return { rows: [] };
496
+ });
497
+
498
+ await expect(
499
+ getAgentThreadDebug({ threadId: "other-members-thread" }),
500
+ ).rejects.toMatchObject({ statusCode: 404 });
501
+ });
502
+
461
503
  it("keeps explicit remote sources admin-only", async () => {
462
504
  vi.stubEnv("REMOTE_DATABASE_URL", "postgres://remote/db");
463
505
 
@@ -471,6 +513,28 @@ describe("thread-debug-store", () => {
471
513
  expect(mocks.createDbExec).not.toHaveBeenCalled();
472
514
  });
473
515
 
516
+ it("does not let an environment admin assertion inspect remote transcripts", async () => {
517
+ mocks.orgId = "org-1";
518
+ mocks.ownerEmail = "platform-admin@example.com";
519
+ mocks.delegatedOrgMember = true;
520
+ mocks.dispatchConfig.adminEmails = ["platform-admin@example.com"];
521
+ mocks.currentExecute.mockImplementation(async ({ sql }) =>
522
+ sql.includes("SELECT role FROM org_members")
523
+ ? { rows: [{ role: "owner" }] }
524
+ : { rows: [] },
525
+ );
526
+ vi.stubEnv("REMOTE_DATABASE_URL", "postgres://remote/db");
527
+
528
+ await expect(
529
+ listAgentRunFailures({ sourceId: "remote" }),
530
+ ).rejects.toMatchObject({
531
+ statusCode: 403,
532
+ message:
533
+ "Only Dispatch admins can inspect thread databases from other apps.",
534
+ });
535
+ expect(mocks.createDbExec).not.toHaveBeenCalled();
536
+ });
537
+
474
538
  it("prevents organization admins from requesting an owner outside their organization", async () => {
475
539
  mocks.orgId = "org-1";
476
540
  mocks.currentExecute.mockImplementation(async ({ sql }) => {
@@ -4,7 +4,7 @@ import {
4
4
  type AgentFailureRegime,
5
5
  } from "@agent-native/core/agent/engine";
6
6
  import { createDbExec, getDbExec, type DbExec } from "@agent-native/core/db";
7
- import { getAppConfig } from "@agent-native/core/server";
7
+ import { getAppConfig, readOrgMemberRole } from "@agent-native/core/server";
8
8
  import { ForbiddenError } from "@agent-native/core/sharing";
9
9
 
10
10
  import {
@@ -436,14 +436,7 @@ async function viewerOrgRole(
436
436
  viewerEmail: string,
437
437
  ): Promise<string | null> {
438
438
  if (!orgId) return null;
439
- const rows = await currentDbRows<{ role?: string }>(
440
- `SELECT role FROM org_members
441
- WHERE org_id = ? AND LOWER(email) = ?
442
- AND federation_removal_pending_at IS NULL
443
- LIMIT 1`,
444
- [orgId, viewerEmail.toLowerCase()],
445
- );
446
- return typeof rows[0]?.role === "string" ? rows[0].role : null;
439
+ return readOrgMemberRole(orgId, viewerEmail);
447
440
  }
448
441
 
449
442
  async function currentOrgMembers(orgId: string | null): Promise<string[]> {