@agent-native/dispatch 0.40.4 → 0.40.6

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 (66) hide show
  1. package/CHANGELOG.md +28 -20
  2. package/dist/actions/provider-api-register.d.ts +8 -8
  3. package/dist/components/app-open-actions.d.ts +1 -1
  4. package/dist/components/app-open-actions.d.ts.map +1 -1
  5. package/dist/components/app-open-actions.js +1 -1
  6. package/dist/components/app-open-actions.js.map +1 -1
  7. package/dist/components/app-row-settings.js +1 -1
  8. package/dist/components/app-row-settings.js.map +1 -1
  9. package/dist/components/chat-home-apps.js +1 -1
  10. package/dist/components/chat-home-apps.js.map +1 -1
  11. package/dist/components/deferred-chat-components.d.ts +31 -0
  12. package/dist/components/deferred-chat-components.d.ts.map +1 -0
  13. package/dist/components/deferred-chat-components.js +28 -0
  14. package/dist/components/deferred-chat-components.js.map +1 -0
  15. package/dist/components/layout/Header.js +1 -1
  16. package/dist/components/layout/Header.js.map +1 -1
  17. package/dist/components/layout/Layout.d.ts +2 -1
  18. package/dist/components/layout/Layout.d.ts.map +1 -1
  19. package/dist/components/layout/Layout.js +3 -3
  20. package/dist/components/layout/Layout.js.map +1 -1
  21. package/dist/components/workspace-app-card.js +1 -1
  22. package/dist/components/workspace-app-card.js.map +1 -1
  23. package/dist/components/workspace-app-host.d.ts +1 -1
  24. package/dist/components/workspace-app-host.d.ts.map +1 -1
  25. package/dist/components/workspace-app-host.js +3 -2
  26. package/dist/components/workspace-app-host.js.map +1 -1
  27. package/dist/lib/workspace-apps.js +1 -1
  28. package/dist/lib/workspace-apps.js.map +1 -1
  29. package/dist/routes/pages/browser-chat.js +1 -1
  30. package/dist/routes/pages/browser-chat.js.map +1 -1
  31. package/dist/routes/pages/chat.js +2 -2
  32. package/dist/routes/pages/chat.js.map +1 -1
  33. package/dist/server/lib/private-icon-assets.d.ts +7 -0
  34. package/dist/server/lib/private-icon-assets.d.ts.map +1 -0
  35. package/dist/server/lib/private-icon-assets.js +176 -0
  36. package/dist/server/lib/private-icon-assets.js.map +1 -0
  37. package/dist/server/plugins/auth.d.ts.map +1 -1
  38. package/dist/server/plugins/auth.js +6 -1
  39. package/dist/server/plugins/auth.js.map +1 -1
  40. package/dist/server/plugins/core-routes.d.ts.map +1 -1
  41. package/dist/server/plugins/core-routes.js +2 -0
  42. package/dist/server/plugins/core-routes.js.map +1 -1
  43. package/package.json +3 -3
  44. package/src/components/app-open-actions.tsx +1 -1
  45. package/src/components/app-row-settings.tsx +1 -1
  46. package/src/components/chat-home-apps.tsx +1 -1
  47. package/src/components/deferred-chat-components.tsx +96 -0
  48. package/src/components/layout/Header.tsx +1 -1
  49. package/src/components/layout/Layout.app-chat.spec.ts +7 -0
  50. package/src/components/layout/Layout.spec.tsx +6 -3
  51. package/src/components/layout/Layout.tsx +23 -21
  52. package/src/components/workspace-app-card.tsx +1 -1
  53. package/src/components/workspace-app-chat-rail.spec.tsx +5 -2
  54. package/src/components/workspace-app-host.spec.tsx +5 -9
  55. package/src/components/workspace-app-host.tsx +4 -6
  56. package/src/lib/workspace-apps.ts +1 -1
  57. package/src/routes/pages/browser-chat.spec.tsx +1 -1
  58. package/src/routes/pages/browser-chat.tsx +1 -1
  59. package/src/routes/pages/chat.spec.tsx +4 -1
  60. package/src/routes/pages/chat.tsx +2 -2
  61. package/src/server/lib/private-icon-assets.spec.ts +498 -0
  62. package/src/server/lib/private-icon-assets.ts +244 -0
  63. package/src/server/plugins/auth.spec.ts +1 -1
  64. package/src/server/plugins/auth.ts +6 -1
  65. package/src/server/plugins/core-routes.private-icons.spec.ts +36 -0
  66. package/src/server/plugins/core-routes.ts +5 -0
@@ -0,0 +1,244 @@
1
+ import { verifyA2AToken } from "@agent-native/core/a2a";
2
+ import {
3
+ getIconAsset,
4
+ IconUploadBodyError,
5
+ readIconUploadFormData,
6
+ readIconAssetForAuthorizedReference,
7
+ listIconAssets,
8
+ putIconAsset,
9
+ } from "@agent-native/core/icon-assets";
10
+ import { isOrgMember, resolveOrgByDomain } from "@agent-native/core/org";
11
+ import { getSession, runWithRequestContext } from "@agent-native/core/server";
12
+
13
+ const MAX_ICON_BYTES = 5 * 1024 * 1024;
14
+ const ICON_ID = /^[A-Za-z0-9_-]{8,128}$/;
15
+ const ICON_PATH = /^\/([A-Za-z0-9_-]{8,128})\/?$/;
16
+
17
+ type IconScope =
18
+ | "private-icon:upload"
19
+ | "private-icon:list"
20
+ | "private-icon:verify-owner"
21
+ | "private-icon:read";
22
+
23
+ type PeerIdentity = {
24
+ email: string;
25
+ orgId: string;
26
+ assetId?: string;
27
+ };
28
+
29
+ interface PrivateIconEvent {
30
+ url: URL;
31
+ req: Request;
32
+ }
33
+
34
+ const PRIVATE_HEADERS = {
35
+ "cache-control": "private, no-store",
36
+ "x-content-type-options": "nosniff",
37
+ "referrer-policy": "no-referrer",
38
+ };
39
+
40
+ function errorResponse(status: number, message: string): Response {
41
+ return Response.json(
42
+ { error: message },
43
+ { status, headers: PRIVATE_HEADERS },
44
+ );
45
+ }
46
+
47
+ async function peerIdentity(
48
+ event: PrivateIconEvent,
49
+ scope: IconScope,
50
+ ): Promise<PeerIdentity | null> {
51
+ const authorization = event.req.headers.get("authorization");
52
+ const match = /^Bearer ([^\s]+)$/.exec(authorization ?? "");
53
+ if (!match) return null;
54
+ const verified = await verifyA2AToken(match[1]!, event as never, {
55
+ routePrefix: "private-icon",
56
+ globalSecretOnly: true,
57
+ includeClaims: true,
58
+ });
59
+ const claims = verified.claims;
60
+ if (
61
+ !verified.email?.trim() ||
62
+ !claims ||
63
+ claims.scope !== scope ||
64
+ !claims.aud ||
65
+ !claims.iss
66
+ ) {
67
+ return null;
68
+ }
69
+
70
+ const claimedOrgId =
71
+ typeof claims.org_id === "string" ? claims.org_id.trim() : "";
72
+ const domain = verified.orgDomain?.trim().toLowerCase();
73
+ const mapped = domain ? await resolveOrgByDomain(domain) : null;
74
+ if (domain && !mapped) return null;
75
+ if (mapped && claimedOrgId && mapped.orgId !== claimedOrgId) return null;
76
+ const orgId = mapped?.orgId ?? claimedOrgId;
77
+ if (!orgId) return null;
78
+
79
+ // `asset_id` is a capability fence on service reads. Content checks an
80
+ // accessible resource reference before signing this exact ID.
81
+ const assetId =
82
+ typeof claims.asset_id === "string" ? claims.asset_id.trim() : undefined;
83
+ if (scope === "private-icon:read" && (!assetId || !ICON_ID.test(assetId))) {
84
+ return null;
85
+ }
86
+ return { email: verified.email.trim().toLowerCase(), orgId, assetId };
87
+ }
88
+
89
+ async function ownerIdentity(
90
+ event: PrivateIconEvent,
91
+ scope: IconScope,
92
+ ): Promise<{ email: string; orgId?: string } | null> {
93
+ if (event.req.headers.has("authorization")) {
94
+ const peer = await peerIdentity(event, scope);
95
+ if (!peer || !(await isOrgMember(peer.orgId, peer.email))) return null;
96
+ return { email: peer.email, orgId: peer.orgId };
97
+ }
98
+ if (scope === "private-icon:verify-owner") return null;
99
+ const session = await getSession(event as never);
100
+ if (!session?.email) return null;
101
+ if (session.orgId && !(await isOrgMember(session.orgId, session.email))) {
102
+ return null;
103
+ }
104
+ return { email: session.email.trim().toLowerCase(), orgId: session.orgId };
105
+ }
106
+
107
+ export function createPrivateIconAssetsHandler() {
108
+ return async (event: PrivateIconEvent): Promise<Response> => {
109
+ const method = event.req.method.toUpperCase();
110
+ const path =
111
+ new URL(event.req.url).pathname.replace(
112
+ /^\/_agent-native\/private-icons(?=\/|$)/,
113
+ "",
114
+ ) || "/";
115
+ const idMatch = ICON_PATH.exec(path);
116
+ if (path !== "/" && !idMatch) {
117
+ return errorResponse(404, "Icon not found");
118
+ }
119
+
120
+ if (path === "/" && method === "POST") {
121
+ const owner = await ownerIdentity(event, "private-icon:upload");
122
+ if (!owner) return errorResponse(401, "Unauthorized");
123
+ const length = Number(event.req.headers.get("content-length"));
124
+ if (!Number.isSafeInteger(length) || length <= 0) {
125
+ return errorResponse(411, "Content length required");
126
+ }
127
+ if (
128
+ !event.req.headers
129
+ .get("content-type")
130
+ ?.startsWith("multipart/form-data;")
131
+ ) {
132
+ return errorResponse(415, "Multipart icon upload required");
133
+ }
134
+ let files: FormDataEntryValue[];
135
+ try {
136
+ files = (await readIconUploadFormData(event.req)).getAll("file");
137
+ } catch (error) {
138
+ if (error instanceof IconUploadBodyError)
139
+ return errorResponse(error.statusCode, error.message);
140
+ throw error;
141
+ }
142
+ const file = files.length === 1 ? files[0] : undefined;
143
+ if (!file || typeof file === "string") {
144
+ return errorResponse(400, "A valid icon file is required");
145
+ }
146
+ const data = new Uint8Array(await file.arrayBuffer());
147
+ if (data.byteLength === 0 || data.byteLength > MAX_ICON_BYTES) {
148
+ return errorResponse(413, "Icon too large");
149
+ }
150
+ if (
151
+ !["image/png", "image/jpeg", "image/webp", "image/svg+xml"].includes(
152
+ file.type,
153
+ )
154
+ ) {
155
+ return errorResponse(415, "Unsupported icon type");
156
+ }
157
+ const asset = await runWithRequestContext(
158
+ { userEmail: owner.email, orgId: owner.orgId },
159
+ () =>
160
+ putIconAsset({
161
+ data,
162
+ mimeType: file.type ?? "",
163
+ filename: file.name,
164
+ ownerEmail: owner.email,
165
+ orgId: owner.orgId,
166
+ }),
167
+ );
168
+ return Response.json(
169
+ {
170
+ id: asset.id,
171
+ filename: asset.filename,
172
+ mimeType: asset.mimeType,
173
+ size: asset.size,
174
+ contentType: asset.mimeType,
175
+ byteLength: asset.size,
176
+ },
177
+ { status: 201, headers: PRIVATE_HEADERS },
178
+ );
179
+ }
180
+
181
+ if (path === "/" && method === "GET") {
182
+ const owner = await ownerIdentity(event, "private-icon:list");
183
+ if (!owner) return errorResponse(401, "Unauthorized");
184
+ const assets = await runWithRequestContext(
185
+ { userEmail: owner.email, orgId: owner.orgId },
186
+ () =>
187
+ listIconAssets({
188
+ ownerEmail: owner.email,
189
+ orgId: owner.orgId,
190
+ limit: 100,
191
+ }),
192
+ );
193
+ return Response.json(
194
+ {
195
+ assets: assets.map(({ id, filename, mimeType, size, createdAt }) => ({
196
+ id,
197
+ filename,
198
+ mimeType,
199
+ size,
200
+ createdAt,
201
+ })),
202
+ },
203
+ { headers: PRIVATE_HEADERS },
204
+ );
205
+ }
206
+
207
+ const id = idMatch?.[1];
208
+ if (id && method === "HEAD") {
209
+ const owner = await ownerIdentity(event, "private-icon:verify-owner");
210
+ if (!owner) return errorResponse(401, "Unauthorized");
211
+ const asset = await runWithRequestContext(
212
+ { userEmail: owner.email, orgId: owner.orgId },
213
+ () => getIconAsset(id, { ownerEmail: owner.email, orgId: owner.orgId }),
214
+ );
215
+ if (!asset) return errorResponse(404, "Icon not found");
216
+ return new Response(null, { status: 200, headers: PRIVATE_HEADERS });
217
+ }
218
+
219
+ if (id && method === "GET") {
220
+ const peer = await peerIdentity(event, "private-icon:read");
221
+ if (!peer) return errorResponse(401, "Unauthorized");
222
+ if (peer.assetId !== id) {
223
+ return errorResponse(403, "Icon access denied");
224
+ }
225
+ const icon = await runWithRequestContext(
226
+ { userEmail: peer.email, orgId: peer.orgId },
227
+ () => readIconAssetForAuthorizedReference(id, { orgId: peer.orgId }),
228
+ );
229
+ if (!icon) return errorResponse(404, "Icon not found");
230
+ const headers = new Headers(PRIVATE_HEADERS);
231
+ headers.set("content-type", icon.mimeType);
232
+ headers.set("content-length", String(icon.data.byteLength));
233
+ if (icon.mimeType === "image/svg+xml") {
234
+ headers.set(
235
+ "content-security-policy",
236
+ "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:",
237
+ );
238
+ }
239
+ return new Response(Buffer.from(icon.data), { headers });
240
+ }
241
+
242
+ return errorResponse(405, "Method not allowed");
243
+ };
244
+ }
@@ -42,7 +42,7 @@ describe("dispatchAuthPlugin", () => {
42
42
  expect(mocks.createAuthPlugin).toHaveBeenCalledWith(
43
43
  expect.objectContaining({
44
44
  googleOnly: true,
45
- publicPaths,
45
+ publicPaths: [...publicPaths, "/_agent-native/private-icons"],
46
46
  workspaceAppPublicPaths: ["/"],
47
47
  }),
48
48
  );
@@ -23,7 +23,12 @@ const dispatchAuthPlugin = async (nitroApp: any) => {
23
23
  googleOnly,
24
24
  marketing: marketing as any,
25
25
  workspaceAppPublicPaths: ["/"],
26
- publicPaths: authConfig.publicPaths,
26
+ // This route authenticates its own audience-bound service bearer before any
27
+ // asset lookup; the session guard cannot authenticate peer services.
28
+ publicPaths: [
29
+ ...(authConfig.publicPaths ?? []),
30
+ "/_agent-native/private-icons",
31
+ ],
27
32
  });
28
33
  return plugin(nitroApp);
29
34
  };
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ use: vi.fn(),
5
+ corePlugin: vi.fn(),
6
+ iconHandler: vi.fn(),
7
+ chatHandler: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("@agent-native/core/server", () => ({
11
+ createCoreRoutesPlugin: () => mocks.corePlugin,
12
+ getH3App: () => ({ use: mocks.use }),
13
+ }));
14
+ vi.mock("../lib/env-config.js", () => ({ envKeys: [] }));
15
+ vi.mock("../lib/onboarding-steps.js", () => ({
16
+ registerDispatchOnboardingSteps: vi.fn(),
17
+ }));
18
+ vi.mock("../lib/private-icon-assets.js", () => ({
19
+ createPrivateIconAssetsHandler: () => mocks.iconHandler,
20
+ }));
21
+ vi.mock("../lib/workspace-app-chat-proxy.js", () => ({
22
+ createWorkspaceAppChatProxyHandler: () => mocks.chatHandler,
23
+ }));
24
+
25
+ import dispatchCoreRoutesPlugin from "./core-routes.js";
26
+
27
+ describe("Dispatch core route mounts", () => {
28
+ it("mounts the private icon route consumed by the Dispatch template", () => {
29
+ const nitroApp = {};
30
+ dispatchCoreRoutesPlugin(nitroApp);
31
+ expect(mocks.use).toHaveBeenCalledWith(
32
+ "/_agent-native/private-icons",
33
+ mocks.iconHandler,
34
+ );
35
+ });
36
+ });
@@ -7,6 +7,7 @@ import {
7
7
  import { WORKSPACE_APP_CHAT_PROXY_PREFIX } from "../../shared/workspace-app-chat.js";
8
8
  import { envKeys } from "../lib/env-config.js";
9
9
  import { registerDispatchOnboardingSteps } from "../lib/onboarding-steps.js";
10
+ import { createPrivateIconAssetsHandler } from "../lib/private-icon-assets.js";
10
11
  import { createWorkspaceAppChatProxyHandler } from "../lib/workspace-app-chat-proxy.js";
11
12
 
12
13
  // Register before the core plugin so "create your first app" (order 5) appears
@@ -24,6 +25,10 @@ const dispatchCoreRoutesPlugin: NitroPluginDef = (nitroApp) => {
24
25
  WORKSPACE_APP_CHAT_PROXY_PREFIX,
25
26
  createWorkspaceAppChatProxyHandler(),
26
27
  );
28
+ getH3App(nitroApp).use(
29
+ "/_agent-native/private-icons",
30
+ createPrivateIconAssetsHandler(),
31
+ );
27
32
  return coreInit;
28
33
  };
29
34