@agent-native/dispatch 0.40.30 → 0.40.31

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 (44) hide show
  1. package/CHANGELOG.md +9 -13
  2. package/dist/actions/list-connected-agents.js +7 -3
  3. package/dist/actions/list-connected-agents.js.map +1 -1
  4. package/dist/actions/list-workspace-connections.d.ts.map +1 -1
  5. package/dist/actions/list-workspace-connections.js +13 -18
  6. package/dist/actions/list-workspace-connections.js.map +1 -1
  7. package/dist/components/action-query-error.d.ts +2 -1
  8. package/dist/components/action-query-error.d.ts.map +1 -1
  9. package/dist/components/action-query-error.js +25 -2
  10. package/dist/components/action-query-error.js.map +1 -1
  11. package/dist/components/chat-home-apps.d.ts.map +1 -1
  12. package/dist/components/chat-home-apps.js +2 -2
  13. package/dist/components/chat-home-apps.js.map +1 -1
  14. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  15. package/dist/components/dispatch-control-plane.js +1 -1
  16. package/dist/components/dispatch-control-plane.js.map +1 -1
  17. package/dist/components/layout/Layout.d.ts +1 -0
  18. package/dist/components/layout/Layout.d.ts.map +1 -1
  19. package/dist/components/layout/Layout.js +7 -0
  20. package/dist/components/layout/Layout.js.map +1 -1
  21. package/dist/routes/pages/$appId.d.ts.map +1 -1
  22. package/dist/routes/pages/$appId.js +1 -1
  23. package/dist/routes/pages/$appId.js.map +1 -1
  24. package/dist/routes/pages/apps.js +1 -1
  25. package/dist/routes/pages/apps.js.map +1 -1
  26. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  27. package/dist/server/lib/app-creation-store.js +122 -54
  28. package/dist/server/lib/app-creation-store.js.map +1 -1
  29. package/package.json +3 -3
  30. package/src/actions/list-connected-agents.spec.ts +17 -1
  31. package/src/actions/list-connected-agents.ts +7 -2
  32. package/src/actions/list-workspace-connections.spec.ts +14 -3
  33. package/src/actions/list-workspace-connections.ts +13 -17
  34. package/src/components/action-query-error.spec.tsx +20 -0
  35. package/src/components/action-query-error.tsx +40 -0
  36. package/src/components/chat-home-apps.spec.tsx +32 -1
  37. package/src/components/chat-home-apps.tsx +6 -1
  38. package/src/components/dispatch-control-plane.tsx +1 -0
  39. package/src/components/layout/Layout.tsx +9 -0
  40. package/src/routes/pages/$appId.tsx +1 -0
  41. package/src/routes/pages/apps.spec.tsx +26 -2
  42. package/src/routes/pages/apps.tsx +2 -0
  43. package/src/server/lib/app-creation-store.spec.ts +281 -5
  44. package/src/server/lib/app-creation-store.ts +172 -60
@@ -1,3 +1,4 @@
1
+ import { createHmac } from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
@@ -29,6 +30,20 @@ import {
29
30
  const originalFetch = globalThis.fetch;
30
31
  const settingsKey = "dispatch-app-creation-settings:user:dev@example.test";
31
32
 
33
+ function verifyJwtWithSecret(
34
+ token: string,
35
+ secret: string,
36
+ ): Record<string, unknown> {
37
+ const [header, payload, signature] = token.split(".");
38
+ const expected = createHmac("sha256", secret)
39
+ .update(`${header}.${payload}`)
40
+ .digest("base64url");
41
+ if (signature !== expected || !payload) {
42
+ throw new Error("JWT signature did not match the provided secret.");
43
+ }
44
+ return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
45
+ }
46
+
32
47
  const mocks = vi.hoisted(() => {
33
48
  const settings = new Map<string, unknown>();
34
49
  const state = {
@@ -55,6 +70,8 @@ const mocks = vi.hoisted(() => {
55
70
  getOrgDomain: vi.fn(async (orgId: string) =>
56
71
  orgId ? "example.test" : null,
57
72
  ),
73
+ getOrgA2ASecret: vi.fn(async () => null as string | null),
74
+ signA2AToken: vi.fn(),
58
75
  isWorkspaceAppAccessAllowed: vi.fn(
59
76
  async (): Promise<boolean | "unavailable"> => true,
60
77
  ),
@@ -131,6 +148,18 @@ const mocks = vi.hoisted(() => {
131
148
  };
132
149
  });
133
150
 
151
+ vi.mock("@agent-native/core/a2a", async (importOriginal) => {
152
+ const actual =
153
+ await importOriginal<typeof import("@agent-native/core/a2a")>();
154
+ mocks.signA2AToken.mockImplementation((...args: any[]) =>
155
+ actual.signA2AToken(...args),
156
+ );
157
+ return {
158
+ ...actual,
159
+ signA2AToken: (...args: any[]) => mocks.signA2AToken(...args),
160
+ };
161
+ });
162
+
134
163
  vi.mock("../../db/index.js", async (importOriginal) => {
135
164
  const actual = await importOriginal<typeof import("../../db/index.js")>();
136
165
  return { ...actual, getDb: mocks.getDb };
@@ -166,6 +195,7 @@ vi.mock("@agent-native/core/org", async (importOriginal) => {
166
195
  await importOriginal<typeof import("@agent-native/core/org")>();
167
196
  return {
168
197
  ...actual,
198
+ getOrgA2ASecret: (...args: any[]) => mocks.getOrgA2ASecret(...args),
169
199
  getOrgDomain: (...args: any[]) => mocks.getOrgDomain(...args),
170
200
  isWorkspaceAppAccessAllowed: (...args: any[]) =>
171
201
  mocks.isWorkspaceAppAccessAllowed(...args),
@@ -218,6 +248,8 @@ afterEach(() => {
218
248
  mocks.settings.clear();
219
249
  mocks.getOrgSetting.mockReset();
220
250
  mocks.getOrgSetting.mockResolvedValue(null);
251
+ mocks.getOrgA2ASecret.mockReset();
252
+ mocks.getOrgA2ASecret.mockResolvedValue(null);
221
253
  mocks.isWorkspaceAppAccessAllowed.mockReset();
222
254
  mocks.isWorkspaceAppAccessAllowed.mockResolvedValue(true);
223
255
  mocks.mutateSetting.mockReset();
@@ -600,6 +632,130 @@ describe("listWorkspaceApps", () => {
600
632
  expect(apps[0]?.url).toBe("https://agent-workspace.builder.io/atlas");
601
633
  });
602
634
 
635
+ it("signs the hosted registry request with the caller's organization secret", async () => {
636
+ const fetchMock = vi.fn(
637
+ async () =>
638
+ new Response(JSON.stringify([]), {
639
+ headers: { "content-type": "application/json" },
640
+ }),
641
+ );
642
+ vi.stubGlobal("fetch", fetchMock);
643
+ vi.stubEnv("A2A_SECRET", "dispatch-deployment-secret");
644
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
645
+ mocks.getOrgA2ASecret.mockResolvedValueOnce("workspace-org-secret");
646
+
647
+ await runWithRequestContext(
648
+ { userEmail: "dev@example.test", orgId: "org-1" },
649
+ () => listWorkspaceApps({ includeAgentCards: false }),
650
+ );
651
+
652
+ const authorization = fetchMock.mock.calls[0]?.[1]?.headers
653
+ ?.Authorization as string;
654
+ const token = authorization.slice("Bearer ".length);
655
+ const payload = verifyJwtWithSecret(token, "workspace-org-secret");
656
+ expect(payload).toMatchObject({
657
+ sub: "dev@example.test",
658
+ org_domain: "example.test",
659
+ aud: "https://agent-workspace.builder.io",
660
+ });
661
+ expect(() =>
662
+ verifyJwtWithSecret(token, "dispatch-deployment-secret"),
663
+ ).toThrow();
664
+ });
665
+
666
+ it("reports missing hosted gateway signing credentials instead of sending anonymously", async () => {
667
+ const fetchMock = vi.fn();
668
+ vi.stubGlobal("fetch", fetchMock);
669
+ vi.stubEnv("A2A_SECRET", "");
670
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
671
+ mocks.getOrgA2ASecret.mockResolvedValueOnce(null);
672
+ stubManifest([
673
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
674
+ { id: "clips", name: "Clips", path: "/clips" },
675
+ ]);
676
+
677
+ await expect(
678
+ runWithRequestContext(
679
+ { userEmail: "dev@example.test", orgId: "org-1" },
680
+ () => listWorkspaceApps({ includeAgentCards: false }),
681
+ ),
682
+ ).rejects.toMatchObject({
683
+ statusCode: 503,
684
+ errorCode: "workspace_gateway_a2a_secret_missing",
685
+ });
686
+ expect(fetchMock).not.toHaveBeenCalled();
687
+ });
688
+
689
+ it("reports organization A2A secret lookup failures before using manifest apps", async () => {
690
+ const fetchMock = vi.fn();
691
+ vi.stubGlobal("fetch", fetchMock);
692
+ vi.stubEnv("A2A_SECRET", "dispatch-deployment-secret");
693
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
694
+ stubManifest([
695
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
696
+ { id: "clips", name: "Clips", path: "/clips" },
697
+ ]);
698
+ mocks.getOrgA2ASecret.mockRejectedValueOnce(
699
+ new Error("database unavailable"),
700
+ );
701
+
702
+ await expect(
703
+ runWithRequestContext(
704
+ { userEmail: "dev@example.test", orgId: "org-1" },
705
+ () => listWorkspaceApps({ includeAgentCards: false }),
706
+ ),
707
+ ).rejects.toMatchObject({
708
+ statusCode: 503,
709
+ errorCode: "workspace_gateway_org_a2a_secret_unavailable",
710
+ });
711
+ expect(fetchMock).not.toHaveBeenCalled();
712
+ });
713
+
714
+ it("reports token signing failures before using manifest apps", async () => {
715
+ const fetchMock = vi.fn();
716
+ vi.stubGlobal("fetch", fetchMock);
717
+ vi.stubEnv("A2A_SECRET", "dispatch-deployment-secret");
718
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
719
+ stubManifest([
720
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
721
+ { id: "clips", name: "Clips", path: "/clips" },
722
+ ]);
723
+ mocks.signA2AToken.mockRejectedValueOnce(new Error("signing unavailable"));
724
+
725
+ await expect(
726
+ runWithRequestContext(
727
+ { userEmail: "dev@example.test", orgId: "org-1" },
728
+ () => listWorkspaceApps({ includeAgentCards: false }),
729
+ ),
730
+ ).rejects.toMatchObject({
731
+ statusCode: 503,
732
+ errorCode: "workspace_gateway_token_signing_failed",
733
+ });
734
+ expect(fetchMock).not.toHaveBeenCalled();
735
+ });
736
+
737
+ it("does not hide an organization lookup failure behind manifest apps", async () => {
738
+ const fetchMock = vi.fn();
739
+ vi.stubGlobal("fetch", fetchMock);
740
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
741
+ stubManifest([
742
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
743
+ { id: "clips", name: "Clips", path: "/clips" },
744
+ ]);
745
+ mocks.getOrgDomain.mockRejectedValueOnce(new Error("database unavailable"));
746
+
747
+ await expect(
748
+ runWithRequestContext(
749
+ { userEmail: "dev@example.test", orgId: "org-1" },
750
+ () => listWorkspaceApps({ includeAgentCards: false }),
751
+ ),
752
+ ).rejects.toMatchObject({
753
+ statusCode: 503,
754
+ errorCode: "workspace_gateway_org_domain_unavailable",
755
+ });
756
+ expect(fetchMock).not.toHaveBeenCalled();
757
+ });
758
+
603
759
  it("binds hosted registry tokens to the mounted Dispatch path", async () => {
604
760
  const fetchMock = vi.fn(async () => {
605
761
  return new Response(
@@ -793,6 +949,122 @@ describe("listWorkspaceApps", () => {
793
949
  expect(apps.map((app) => app.id)).toEqual(["dispatch", "clips"]);
794
950
  });
795
951
 
952
+ it("treats an empty hosted registry response as a successful empty list", async () => {
953
+ const fetchMock = vi.fn(
954
+ async () =>
955
+ new Response(JSON.stringify([]), {
956
+ headers: { "content-type": "application/json" },
957
+ }),
958
+ );
959
+ vi.stubGlobal("fetch", fetchMock);
960
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
961
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
962
+ stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
963
+
964
+ const apps = await runWithRequestContext(
965
+ { userEmail: "dev@example.test" },
966
+ () => listWorkspaceApps({ includeAgentCards: false }),
967
+ );
968
+
969
+ expect(apps).toEqual([]);
970
+ expect(fetchMock).toHaveBeenCalledTimes(1);
971
+ });
972
+
973
+ it.each([206, 500, 502, 503])(
974
+ "surfaces hosted registry HTTP %i failures instead of using manifest apps",
975
+ async (status) => {
976
+ const fetchMock = vi.fn(
977
+ async () => new Response("gateway unavailable", { status }),
978
+ );
979
+ vi.stubGlobal("fetch", fetchMock);
980
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
981
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
982
+ stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
983
+
984
+ await expect(
985
+ runWithRequestContext({ userEmail: "dev@example.test" }, () =>
986
+ listWorkspaceApps({ includeAgentCards: false }),
987
+ ),
988
+ ).rejects.toMatchObject({
989
+ statusCode: 503,
990
+ errorCode: `workspace_gateway_http_${status}`,
991
+ });
992
+ expect(fetchMock).toHaveBeenCalledTimes(1);
993
+ },
994
+ );
995
+
996
+ it("surfaces hosted registry transport failures instead of using manifest apps", async () => {
997
+ const fetchMock = vi.fn(async () => {
998
+ throw new TypeError("Network connection failed");
999
+ });
1000
+ vi.stubGlobal("fetch", fetchMock);
1001
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
1002
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
1003
+ stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
1004
+
1005
+ await expect(
1006
+ runWithRequestContext({ userEmail: "dev@example.test" }, () =>
1007
+ listWorkspaceApps({ includeAgentCards: false }),
1008
+ ),
1009
+ ).rejects.toMatchObject({
1010
+ statusCode: 503,
1011
+ errorCode: "workspace_gateway_unavailable",
1012
+ });
1013
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1014
+ });
1015
+
1016
+ it("surfaces malformed hosted registry responses instead of using manifest apps", async () => {
1017
+ const fetchMock = vi.fn(
1018
+ async () =>
1019
+ new Response("not json", {
1020
+ headers: { "content-type": "application/json" },
1021
+ }),
1022
+ );
1023
+ vi.stubGlobal("fetch", fetchMock);
1024
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
1025
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
1026
+ stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
1027
+
1028
+ await expect(
1029
+ runWithRequestContext({ userEmail: "dev@example.test" }, () =>
1030
+ listWorkspaceApps({ includeAgentCards: false }),
1031
+ ),
1032
+ ).rejects.toMatchObject({
1033
+ statusCode: 503,
1034
+ errorCode: "workspace_gateway_invalid_response",
1035
+ });
1036
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1037
+ });
1038
+
1039
+ it.each([
1040
+ [{ id: "dispatch", name: "Dispatch", path: "/dispatch" }, null],
1041
+ [
1042
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
1043
+ { id: "DISPATCH", name: "Duplicate Dispatch", path: "/other" },
1044
+ ],
1045
+ ])("rejects incomplete hosted registry lists: %j", async (apps) => {
1046
+ const fetchMock = vi.fn(
1047
+ async () =>
1048
+ new Response(JSON.stringify(apps), {
1049
+ headers: { "content-type": "application/json" },
1050
+ }),
1051
+ );
1052
+ vi.stubGlobal("fetch", fetchMock);
1053
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
1054
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
1055
+ stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
1056
+
1057
+ await expect(
1058
+ runWithRequestContext({ userEmail: "dev@example.test" }, () =>
1059
+ listWorkspaceApps({ includeAgentCards: false }),
1060
+ ),
1061
+ ).rejects.toMatchObject({
1062
+ statusCode: 503,
1063
+ errorCode: "workspace_gateway_invalid_response",
1064
+ });
1065
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1066
+ });
1067
+
796
1068
  it("passes the Vercel protection bypass to the hosted workspace registry", async () => {
797
1069
  const fetchMock = vi.fn(async () => {
798
1070
  return new Response(
@@ -859,10 +1131,10 @@ describe("listWorkspaceApps", () => {
859
1131
  );
860
1132
  });
861
1133
 
862
- it("does not recursively call the hosted registry action", async () => {
1134
+ it("skips a same-origin hosted registry without credentials", async () => {
863
1135
  const fetchMock = vi.fn();
864
1136
  vi.stubGlobal("fetch", fetchMock);
865
- vi.stubEnv("A2A_SECRET", "test-a2a-secret");
1137
+ vi.stubEnv("A2A_SECRET", "");
866
1138
  vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
867
1139
 
868
1140
  const apps = await runWithRequestContext(
@@ -891,9 +1163,13 @@ describe("listWorkspaceApps", () => {
891
1163
  { userEmail: "dev@example.test", orgId: "org-without-domain" },
892
1164
  () => listWorkspaceApps({ includeAgentCards: false }),
893
1165
  ),
894
- ).rejects.toThrow(
895
- "Workspace apps gateway cannot authenticate without a resolved organization domain.",
896
- );
1166
+ ).rejects.toMatchObject({
1167
+ name: "WorkspaceAppsGatewayAuthorizationError",
1168
+ statusCode: 401,
1169
+ errorCode: "workspace_gateway_org_domain_missing",
1170
+ message:
1171
+ "Workspace apps gateway cannot authenticate without a resolved organization domain.",
1172
+ });
897
1173
  expect(fetchMock).not.toHaveBeenCalled();
898
1174
  });
899
1175
 
@@ -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,
@@ -152,17 +160,15 @@ function requireWorkspaceAppOrgId(): string {
152
160
  return orgId;
153
161
  }
154
162
 
155
- class WorkspaceAppsGatewayAuthorizationError extends Error {
163
+ class WorkspaceAppsGatewayAuthorizationError extends ActionContractError {
156
164
  constructor(
157
- statusCode: 401 | 403,
165
+ statusCode: 401 | 403 | 503,
166
+ errorCode = "workspace_gateway_rejected",
158
167
  message = `Workspace apps gateway rejected the request with HTTP ${statusCode}.`,
159
168
  ) {
160
- super(message);
169
+ super(message, { statusCode, errorCode });
161
170
  this.name = "WorkspaceAppsGatewayAuthorizationError";
162
- this.statusCode = statusCode;
163
171
  }
164
-
165
- statusCode: 401 | 403;
166
172
  }
167
173
 
168
174
  function warnWorkspaceAppsGatewayDenial(
@@ -871,6 +877,21 @@ function parseWorkspaceAppsManifest(parsed: any): WorkspaceAppSummary[] | null {
871
877
  return apps.length ? apps : null;
872
878
  }
873
879
 
880
+ function parseWorkspaceAppsGatewayResponse(
881
+ parsed: unknown,
882
+ ): WorkspaceAppSummary[] | null {
883
+ const rawApps =
884
+ parsed && typeof parsed === "object" && !Array.isArray(parsed)
885
+ ? (parsed as { apps?: unknown }).apps
886
+ : parsed;
887
+ if (!Array.isArray(rawApps)) return null;
888
+ if (rawApps.length === 0) return [];
889
+ const apps = parseWorkspaceAppsManifest(parsed);
890
+ if (!apps || apps.length !== rawApps.length) return null;
891
+ const ids = new Set(apps.map((app) => app.id.toLowerCase()));
892
+ return ids.size === apps.length ? apps : null;
893
+ }
894
+
874
895
  function parseDateMs(value: string | null | undefined): number | null {
875
896
  if (!value) return null;
876
897
  const ms = Date.parse(value);
@@ -1863,37 +1884,89 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
1863
1884
  }
1864
1885
 
1865
1886
  const requestContext = getRequestContext();
1887
+ const isLocalGateway = isLocalWorkspaceGateway(baseUrl);
1888
+ const requestOrigin = requestContext?.requestOrigin;
1889
+ const isSameOriginGateway = requestOrigin
1890
+ ? (() => {
1891
+ try {
1892
+ return new URL(requestOrigin).origin === baseUrl.origin;
1893
+ } catch {
1894
+ // coercion-ok: an invalid request origin cannot prove a self-fetch.
1895
+ return false;
1896
+ }
1897
+ })()
1898
+ : false;
1899
+ if (isSameOriginGateway && !isLocalGateway) return null;
1900
+
1866
1901
  const authHeaders: Record<string, string> = {};
1867
1902
  if (requestContext?.userEmail) {
1868
- const orgDomain = requestContext.orgId
1869
- ? (await getOrgDomain(requestContext.orgId))?.trim().toLowerCase()
1870
- : undefined;
1903
+ let orgDomain: string | undefined;
1904
+ let orgSecret: string | undefined;
1905
+ if (requestContext.orgId) {
1906
+ try {
1907
+ orgDomain =
1908
+ (await getOrgDomain(requestContext.orgId))?.trim().toLowerCase() ||
1909
+ undefined;
1910
+ } catch {
1911
+ throw new WorkspaceAppsGatewayAuthorizationError(
1912
+ 503,
1913
+ "workspace_gateway_org_domain_unavailable",
1914
+ "Workspace apps gateway organization domain could not be read.",
1915
+ );
1916
+ }
1917
+ }
1871
1918
  if (requestContext.orgId && !orgDomain) {
1872
1919
  throw new WorkspaceAppsGatewayAuthorizationError(
1873
1920
  401,
1921
+ "workspace_gateway_org_domain_missing",
1874
1922
  "Workspace apps gateway cannot authenticate without a resolved organization domain.",
1875
1923
  );
1876
1924
  }
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
- },
1925
+ if (requestContext.orgId) {
1926
+ try {
1927
+ orgSecret =
1928
+ (await getOrgA2ASecret(requestContext.orgId))?.trim() || undefined;
1929
+ } catch {
1930
+ throw new WorkspaceAppsGatewayAuthorizationError(
1931
+ 503,
1932
+ "workspace_gateway_org_a2a_secret_unavailable",
1933
+ "Workspace apps gateway organization A2A secret could not be read.",
1934
+ );
1935
+ }
1936
+ }
1937
+ const globalSecret = getGlobalA2ASecret();
1938
+ const allowLocalUnauthenticatedRequest =
1939
+ isLocalGateway && !orgSecret && !globalSecret;
1940
+ if (!orgSecret && !globalSecret && !allowLocalUnauthenticatedRequest) {
1941
+ throw new WorkspaceAppsGatewayAuthorizationError(
1942
+ 503,
1943
+ "workspace_gateway_a2a_secret_missing",
1944
+ "Workspace apps gateway cannot authenticate because no A2A secret is configured.",
1890
1945
  );
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.
1946
+ }
1947
+ if (!allowLocalUnauthenticatedRequest) {
1948
+ try {
1949
+ const token = await signA2AToken(
1950
+ requestContext.userEmail,
1951
+ orgDomain,
1952
+ orgSecret,
1953
+ {
1954
+ expiresIn: "1m",
1955
+ preferGlobalSecret: false,
1956
+ audience: canonicalA2AAudience(
1957
+ baseUrl.toString(),
1958
+ getConfiguredAppBasePath() || undefined,
1959
+ ),
1960
+ },
1961
+ );
1962
+ authHeaders.Authorization = `Bearer ${token}`;
1963
+ } catch {
1964
+ throw new WorkspaceAppsGatewayAuthorizationError(
1965
+ 503,
1966
+ "workspace_gateway_token_signing_failed",
1967
+ "Workspace apps gateway could not sign the authenticated request.",
1968
+ );
1969
+ }
1897
1970
  }
1898
1971
  }
1899
1972
 
@@ -1923,9 +1996,34 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
1923
1996
  const protectedRedirect = protectionHeaders["x-vercel-protection-bypass"]
1924
1997
  ? { redirect: "manual" as const }
1925
1998
  : {};
1999
+ const readAppsResponse = async (response: Response) => {
2000
+ let payload: unknown;
2001
+ try {
2002
+ payload = await response.json();
2003
+ } catch {
2004
+ throw new ActionContractError(
2005
+ "Workspace apps gateway returned an unreadable response.",
2006
+ {
2007
+ statusCode: 503,
2008
+ errorCode: "workspace_gateway_invalid_response",
2009
+ },
2010
+ );
2011
+ }
2012
+ const apps = parseWorkspaceAppsGatewayResponse(payload);
2013
+ if (!apps) {
2014
+ throw new ActionContractError(
2015
+ "Workspace apps gateway returned an invalid response.",
2016
+ {
2017
+ statusCode: 503,
2018
+ errorCode: "workspace_gateway_invalid_response",
2019
+ },
2020
+ );
2021
+ }
2022
+ return { apps };
2023
+ };
1926
2024
 
1927
2025
  try {
1928
- if (isLocalWorkspaceGateway(baseUrl)) {
2026
+ if (isLocalGateway) {
1929
2027
  const localResponse = await fetch(
1930
2028
  gatewayUrl(WORKSPACE_APPS_GATEWAY_PATH),
1931
2029
  {
@@ -1935,29 +2033,25 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
1935
2033
  },
1936
2034
  );
1937
2035
  if (localResponse.status === 401 || localResponse.status === 403) {
1938
- throw new WorkspaceAppsGatewayAuthorizationError(localResponse.status);
2036
+ throw new WorkspaceAppsGatewayAuthorizationError(
2037
+ localResponse.status,
2038
+ "workspace_gateway_rejected",
2039
+ );
1939
2040
  }
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),
2041
+ if (localResponse.status !== 404 && localResponse.status !== 200) {
2042
+ throw new ActionContractError(
2043
+ `Workspace apps gateway returned HTTP ${localResponse.status}.`,
2044
+ {
2045
+ statusCode: 503,
2046
+ errorCode: `workspace_gateway_http_${localResponse.status}`,
2047
+ },
1945
2048
  );
1946
- return apps ? { apps } : null;
2049
+ }
2050
+ if (localResponse.status === 200) {
2051
+ return await readAppsResponse(localResponse);
1947
2052
  }
1948
2053
  }
1949
2054
 
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
2055
  if (isSameOriginGateway || !authHeaders.Authorization) return null;
1962
2056
  const actionUrl = gatewayUrl(WORKSPACE_APPS_ACTION_PATH);
1963
2057
  actionUrl.searchParams.set("includeAgentCards", "false");
@@ -1968,18 +2062,31 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
1968
2062
  signal: controller.signal,
1969
2063
  });
1970
2064
  if (actionResponse.status === 401 || actionResponse.status === 403) {
1971
- throw new WorkspaceAppsGatewayAuthorizationError(actionResponse.status);
2065
+ throw new WorkspaceAppsGatewayAuthorizationError(
2066
+ actionResponse.status,
2067
+ "workspace_gateway_rejected",
2068
+ );
1972
2069
  }
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;
2070
+ if (actionResponse.status === 404) return null;
2071
+ if (actionResponse.status !== 200) {
2072
+ throw new ActionContractError(
2073
+ `Workspace apps gateway returned HTTP ${actionResponse.status}.`,
2074
+ {
2075
+ statusCode: 503,
2076
+ errorCode: `workspace_gateway_http_${actionResponse.status}`,
2077
+ },
2078
+ );
2079
+ }
2080
+ return await readAppsResponse(actionResponse);
1980
2081
  } catch (error) {
1981
- if (error instanceof WorkspaceAppsGatewayAuthorizationError) throw error;
1982
- return null;
2082
+ if (error instanceof ActionContractError) throw error;
2083
+ throw new ActionContractError(
2084
+ "Workspace apps gateway could not be reached.",
2085
+ {
2086
+ statusCode: 503,
2087
+ errorCode: "workspace_gateway_unavailable",
2088
+ },
2089
+ );
1983
2090
  } finally {
1984
2091
  clearTimeout(timeout);
1985
2092
  }
@@ -2260,6 +2367,9 @@ async function listWorkspaceAppsWithPrincipal(
2260
2367
  if (gatewayApps) {
2261
2368
  return finalize(gatewayApps.apps);
2262
2369
  }
2370
+ if (gatewayDenial && gatewayDenial.statusCode >= 500) {
2371
+ throw gatewayDenial;
2372
+ }
2263
2373
  const unverified = gatewayDenial !== null;
2264
2374
 
2265
2375
  const workspaceRoot = findWorkspaceRoot();
@@ -2284,7 +2394,9 @@ async function listWorkspaceAppsWithPrincipal(
2284
2394
  // means the registry will not show this reader its apps, so they get the
2285
2395
  // deployment's own list instead, and none of it is recorded. (A manifest
2286
2396
  // answers earlier for both, with the warning above.)
2287
- if (gatewayDenial?.statusCode === 401) throw gatewayDenial;
2397
+ if (gatewayDenial && gatewayDenial.statusCode !== 403) {
2398
+ throw gatewayDenial;
2399
+ }
2288
2400
  warnWorkspaceAppsGatewayDenial(gatewayDenial, "deployment's own app list");
2289
2401
 
2290
2402
  if (!workspaceRoot) {