@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.
- package/CHANGELOG.md +28 -20
- package/dist/actions/provider-api-register.d.ts +8 -8
- package/dist/components/app-open-actions.d.ts +1 -1
- package/dist/components/app-open-actions.d.ts.map +1 -1
- package/dist/components/app-open-actions.js +1 -1
- package/dist/components/app-open-actions.js.map +1 -1
- package/dist/components/app-row-settings.js +1 -1
- package/dist/components/app-row-settings.js.map +1 -1
- package/dist/components/chat-home-apps.js +1 -1
- package/dist/components/chat-home-apps.js.map +1 -1
- package/dist/components/deferred-chat-components.d.ts +31 -0
- package/dist/components/deferred-chat-components.d.ts.map +1 -0
- package/dist/components/deferred-chat-components.js +28 -0
- package/dist/components/deferred-chat-components.js.map +1 -0
- package/dist/components/layout/Header.js +1 -1
- package/dist/components/layout/Header.js.map +1 -1
- package/dist/components/layout/Layout.d.ts +2 -1
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +3 -3
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/workspace-app-card.js +1 -1
- package/dist/components/workspace-app-card.js.map +1 -1
- package/dist/components/workspace-app-host.d.ts +1 -1
- package/dist/components/workspace-app-host.d.ts.map +1 -1
- package/dist/components/workspace-app-host.js +3 -2
- package/dist/components/workspace-app-host.js.map +1 -1
- package/dist/lib/workspace-apps.js +1 -1
- package/dist/lib/workspace-apps.js.map +1 -1
- package/dist/routes/pages/browser-chat.js +1 -1
- package/dist/routes/pages/browser-chat.js.map +1 -1
- package/dist/routes/pages/chat.js +2 -2
- package/dist/routes/pages/chat.js.map +1 -1
- package/dist/server/lib/private-icon-assets.d.ts +7 -0
- package/dist/server/lib/private-icon-assets.d.ts.map +1 -0
- package/dist/server/lib/private-icon-assets.js +176 -0
- package/dist/server/lib/private-icon-assets.js.map +1 -0
- package/dist/server/plugins/auth.d.ts.map +1 -1
- package/dist/server/plugins/auth.js +6 -1
- package/dist/server/plugins/auth.js.map +1 -1
- package/dist/server/plugins/core-routes.d.ts.map +1 -1
- package/dist/server/plugins/core-routes.js +2 -0
- package/dist/server/plugins/core-routes.js.map +1 -1
- package/package.json +3 -3
- package/src/components/app-open-actions.tsx +1 -1
- package/src/components/app-row-settings.tsx +1 -1
- package/src/components/chat-home-apps.tsx +1 -1
- package/src/components/deferred-chat-components.tsx +96 -0
- package/src/components/layout/Header.tsx +1 -1
- package/src/components/layout/Layout.app-chat.spec.ts +7 -0
- package/src/components/layout/Layout.spec.tsx +6 -3
- package/src/components/layout/Layout.tsx +23 -21
- package/src/components/workspace-app-card.tsx +1 -1
- package/src/components/workspace-app-chat-rail.spec.tsx +5 -2
- package/src/components/workspace-app-host.spec.tsx +5 -9
- package/src/components/workspace-app-host.tsx +4 -6
- package/src/lib/workspace-apps.ts +1 -1
- package/src/routes/pages/browser-chat.spec.tsx +1 -1
- package/src/routes/pages/browser-chat.tsx +1 -1
- package/src/routes/pages/chat.spec.tsx +4 -1
- package/src/routes/pages/chat.tsx +2 -2
- package/src/server/lib/private-icon-assets.spec.ts +498 -0
- package/src/server/lib/private-icon-assets.ts +244 -0
- package/src/server/plugins/auth.spec.ts +1 -1
- package/src/server/plugins/auth.ts +6 -1
- package/src/server/plugins/core-routes.private-icons.spec.ts +36 -0
- package/src/server/plugins/core-routes.ts +5 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { MAX_ICON_MULTIPART_BYTES } from "../../../../core/src/icon-assets/multipart.js";
|
|
6
|
+
import { getH3App } from "../../../../core/src/server/framework-request-handler.js";
|
|
7
|
+
|
|
8
|
+
const mocks = vi.hoisted(() => ({
|
|
9
|
+
verifyA2AToken: vi.fn(),
|
|
10
|
+
resolveOrgByDomain: vi.fn(),
|
|
11
|
+
isOrgMember: vi.fn(),
|
|
12
|
+
getSession: vi.fn(),
|
|
13
|
+
getIconAsset: vi.fn(),
|
|
14
|
+
listIconAssets: vi.fn(),
|
|
15
|
+
putIconAsset: vi.fn(),
|
|
16
|
+
readIconAssetForAuthorizedReference: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock("@agent-native/core/a2a", () => ({
|
|
20
|
+
verifyA2AToken: mocks.verifyA2AToken,
|
|
21
|
+
}));
|
|
22
|
+
vi.mock("../../../../core/src/deploy/route-discovery.js", () => ({
|
|
23
|
+
getMissingDefaultPlugins: vi.fn(async () => []),
|
|
24
|
+
}));
|
|
25
|
+
vi.mock("@agent-native/core/org", () => ({
|
|
26
|
+
resolveOrgByDomain: mocks.resolveOrgByDomain,
|
|
27
|
+
isOrgMember: mocks.isOrgMember,
|
|
28
|
+
}));
|
|
29
|
+
vi.mock("@agent-native/core/server", () => ({
|
|
30
|
+
getSession: mocks.getSession,
|
|
31
|
+
runWithRequestContext: (_context: unknown, run: () => unknown) => run(),
|
|
32
|
+
}));
|
|
33
|
+
vi.mock("@agent-native/core/icon-assets", async () => ({
|
|
34
|
+
...(await import("../../../../core/src/icon-assets/multipart.js")),
|
|
35
|
+
getIconAsset: mocks.getIconAsset,
|
|
36
|
+
listIconAssets: mocks.listIconAssets,
|
|
37
|
+
putIconAsset: mocks.putIconAsset,
|
|
38
|
+
readIconAssetForAuthorizedReference:
|
|
39
|
+
mocks.readIconAssetForAuthorizedReference,
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
import { createPrivateIconAssetsHandler } from "./private-icon-assets.js";
|
|
43
|
+
|
|
44
|
+
const id = "00000000-0000-4000-8000-000000000001";
|
|
45
|
+
const otherId = "00000000-0000-4000-8000-000000000002";
|
|
46
|
+
|
|
47
|
+
function event(method: string, path = `/${id}`) {
|
|
48
|
+
const url = new URL(`https://dispatch.example.test${path}`);
|
|
49
|
+
return {
|
|
50
|
+
url,
|
|
51
|
+
req: new Request(url, {
|
|
52
|
+
method,
|
|
53
|
+
headers: { authorization: "Bearer example-signed-token" },
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function verified(
|
|
59
|
+
scope: string,
|
|
60
|
+
options: { assetId?: string; orgId?: string } = {},
|
|
61
|
+
) {
|
|
62
|
+
return {
|
|
63
|
+
email: "owner@example.test",
|
|
64
|
+
orgDomain: null,
|
|
65
|
+
claims: {
|
|
66
|
+
sub: "owner@example.test",
|
|
67
|
+
iss: "https://content.example.test",
|
|
68
|
+
aud: "https://dispatch.example.test/private-icon",
|
|
69
|
+
scope,
|
|
70
|
+
org_id: options.orgId ?? "dispatch-org",
|
|
71
|
+
...(options.assetId ? { asset_id: options.assetId } : {}),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function throughMount(request: Request, readOnlyUrl: boolean) {
|
|
77
|
+
const requestEvent = {
|
|
78
|
+
url: new URL(request.url),
|
|
79
|
+
req: request,
|
|
80
|
+
method: request.method,
|
|
81
|
+
path: new URL(request.url).pathname,
|
|
82
|
+
context: {} as Record<string, unknown>,
|
|
83
|
+
res: { status: 200, headers: new Headers() },
|
|
84
|
+
};
|
|
85
|
+
if (readOnlyUrl) {
|
|
86
|
+
const pathname = requestEvent.url.pathname;
|
|
87
|
+
Object.defineProperty(requestEvent.url, "pathname", {
|
|
88
|
+
get: () => pathname,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const middleware: Array<
|
|
92
|
+
(
|
|
93
|
+
event: typeof requestEvent,
|
|
94
|
+
next: () => Promise<unknown>,
|
|
95
|
+
) => Promise<unknown>
|
|
96
|
+
> = [];
|
|
97
|
+
const nitroApp = { h3: { "~middleware": middleware } };
|
|
98
|
+
let receivedPathname: string | undefined;
|
|
99
|
+
const iconHandler = createPrivateIconAssetsHandler();
|
|
100
|
+
getH3App(nitroApp).use("/_agent-native/private-icons", (mountedEvent) => {
|
|
101
|
+
receivedPathname = mountedEvent.url.pathname;
|
|
102
|
+
return iconHandler(mountedEvent);
|
|
103
|
+
});
|
|
104
|
+
let index = 0;
|
|
105
|
+
const next = async (): Promise<unknown> => {
|
|
106
|
+
const handler = middleware[index++];
|
|
107
|
+
return handler
|
|
108
|
+
? handler(requestEvent, next)
|
|
109
|
+
: new Response(null, { status: 404 });
|
|
110
|
+
};
|
|
111
|
+
const response = await next();
|
|
112
|
+
expect(response).toBeInstanceOf(Response);
|
|
113
|
+
expect(requestEvent.req.url).toBe(request.url);
|
|
114
|
+
expect(requestEvent.context._mountedPathname).toBe(
|
|
115
|
+
new URL(request.url).pathname,
|
|
116
|
+
);
|
|
117
|
+
expect(receivedPathname).toBe(
|
|
118
|
+
readOnlyUrl
|
|
119
|
+
? new URL(request.url).pathname
|
|
120
|
+
: new URL(request.url).pathname.slice(
|
|
121
|
+
"/_agent-native/private-icons".length,
|
|
122
|
+
) || "/",
|
|
123
|
+
);
|
|
124
|
+
return response as Response;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
describe("Dispatch private icon assets", () => {
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
vi.resetAllMocks();
|
|
130
|
+
mocks.isOrgMember.mockResolvedValue(true);
|
|
131
|
+
mocks.resolveOrgByDomain.mockResolvedValue(null);
|
|
132
|
+
mocks.getIconAsset.mockResolvedValue({ id });
|
|
133
|
+
mocks.readIconAssetForAuthorizedReference.mockResolvedValue({
|
|
134
|
+
data: Uint8Array.of(137, 80, 78, 71),
|
|
135
|
+
mimeType: "image/png",
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it.each([false, true])(
|
|
140
|
+
"routes collection uploads/listing through the real mount adapter with read-only URL %s",
|
|
141
|
+
async (readOnlyUrl) => {
|
|
142
|
+
mocks.verifyA2AToken.mockResolvedValue(verified("private-icon:upload"));
|
|
143
|
+
mocks.putIconAsset.mockResolvedValue({
|
|
144
|
+
id,
|
|
145
|
+
filename: "mark.png",
|
|
146
|
+
mimeType: "image/png",
|
|
147
|
+
size: 4,
|
|
148
|
+
});
|
|
149
|
+
const form = new FormData();
|
|
150
|
+
form.set(
|
|
151
|
+
"file",
|
|
152
|
+
new File([Uint8Array.of(137, 80, 78, 71)], "mark.png", {
|
|
153
|
+
type: "image/png",
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
const url = "https://dispatch.example.test/_agent-native/private-icons";
|
|
157
|
+
const encoded = new Request(url, { method: "POST", body: form });
|
|
158
|
+
const body = await encoded.arrayBuffer();
|
|
159
|
+
const headers = new Headers(encoded.headers);
|
|
160
|
+
headers.set("authorization", "Bearer example-signed-token");
|
|
161
|
+
headers.set("content-length", String(body.byteLength));
|
|
162
|
+
const uploaded = await throughMount(
|
|
163
|
+
new Request(url, { method: "POST", body, headers }),
|
|
164
|
+
readOnlyUrl,
|
|
165
|
+
);
|
|
166
|
+
expect(uploaded.status).toBe(201);
|
|
167
|
+
expect(await uploaded.json()).toMatchObject({ id });
|
|
168
|
+
|
|
169
|
+
mocks.verifyA2AToken.mockResolvedValue(verified("private-icon:list"));
|
|
170
|
+
mocks.listIconAssets.mockResolvedValue([{ id }]);
|
|
171
|
+
const listed = await throughMount(
|
|
172
|
+
new Request(`${url}/?limit=100`, {
|
|
173
|
+
headers: { authorization: "Bearer example-signed-token" },
|
|
174
|
+
}),
|
|
175
|
+
readOnlyUrl,
|
|
176
|
+
);
|
|
177
|
+
expect(listed.status).toBe(200);
|
|
178
|
+
expect(await listed.json()).toMatchObject({ assets: [{ id }] });
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
it.each([false, true])(
|
|
183
|
+
"routes asset HEAD/GET through the real mount adapter with read-only URL %s",
|
|
184
|
+
async (readOnlyUrl) => {
|
|
185
|
+
const url = `https://dispatch.example.test/_agent-native/private-icons/${id}`;
|
|
186
|
+
mocks.verifyA2AToken.mockResolvedValue(
|
|
187
|
+
verified("private-icon:verify-owner"),
|
|
188
|
+
);
|
|
189
|
+
const owned = await throughMount(
|
|
190
|
+
new Request(url, {
|
|
191
|
+
method: "HEAD",
|
|
192
|
+
headers: { authorization: "Bearer example-signed-token" },
|
|
193
|
+
}),
|
|
194
|
+
readOnlyUrl,
|
|
195
|
+
);
|
|
196
|
+
expect(owned.status).toBe(200);
|
|
197
|
+
mocks.verifyA2AToken.mockResolvedValue(
|
|
198
|
+
verified("private-icon:read", { assetId: id }),
|
|
199
|
+
);
|
|
200
|
+
const read = await throughMount(
|
|
201
|
+
new Request(`${url}/`, {
|
|
202
|
+
headers: { authorization: "Bearer example-signed-token" },
|
|
203
|
+
}),
|
|
204
|
+
readOnlyUrl,
|
|
205
|
+
);
|
|
206
|
+
expect(read.status).toBe(200);
|
|
207
|
+
expect(new Uint8Array(await read.arrayBuffer())).toEqual(
|
|
208
|
+
Uint8Array.of(137, 80, 78, 71),
|
|
209
|
+
);
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
it.each([
|
|
214
|
+
"/_agent-native/private-icons-extra",
|
|
215
|
+
`/_agent-native/private-icons-extra/${id}`,
|
|
216
|
+
`/_agent-native/private-icons/${id}/extra`,
|
|
217
|
+
"/_agent-native/private-icons//",
|
|
218
|
+
])("rejects the invalid full path %s", async (path) => {
|
|
219
|
+
const response = await createPrivateIconAssetsHandler()(event("GET", path));
|
|
220
|
+
expect(response.status).toBe(404);
|
|
221
|
+
expect(mocks.verifyA2AToken).not.toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it.each([false, true])(
|
|
225
|
+
"rejects a repeated mount prefix before auth with read-only URL %s",
|
|
226
|
+
async (readOnlyUrl) => {
|
|
227
|
+
mocks.verifyA2AToken.mockResolvedValue(
|
|
228
|
+
verified("private-icon:read", { assetId: id }),
|
|
229
|
+
);
|
|
230
|
+
const request = new Request(
|
|
231
|
+
`https://dispatch.example.test/_agent-native/private-icons/_agent-native/private-icons/${id}`,
|
|
232
|
+
{ headers: { authorization: "Bearer example-signed-token" } },
|
|
233
|
+
);
|
|
234
|
+
const response = await throughMount(request, readOnlyUrl);
|
|
235
|
+
expect(response.status).toBe(404);
|
|
236
|
+
expect(mocks.verifyA2AToken).not.toHaveBeenCalled();
|
|
237
|
+
expect(mocks.readIconAssetForAuthorizedReference).not.toHaveBeenCalled();
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
it("denies an unverified bearer without a session fallback", async () => {
|
|
242
|
+
mocks.verifyA2AToken.mockResolvedValue({ email: null, orgDomain: null });
|
|
243
|
+
const response = await createPrivateIconAssetsHandler()(event("GET"));
|
|
244
|
+
expect(response.status).toBe(401);
|
|
245
|
+
expect(mocks.getSession).not.toHaveBeenCalled();
|
|
246
|
+
expect(mocks.readIconAssetForAuthorizedReference).not.toHaveBeenCalled();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("denies a token issued for another scope or asset", async () => {
|
|
250
|
+
mocks.verifyA2AToken.mockResolvedValue(verified("private-icon:upload"));
|
|
251
|
+
expect((await createPrivateIconAssetsHandler()(event("GET"))).status).toBe(
|
|
252
|
+
401,
|
|
253
|
+
);
|
|
254
|
+
mocks.verifyA2AToken.mockResolvedValue(
|
|
255
|
+
verified("private-icon:read", { assetId: otherId }),
|
|
256
|
+
);
|
|
257
|
+
expect((await createPrivateIconAssetsHandler()(event("GET"))).status).toBe(
|
|
258
|
+
403,
|
|
259
|
+
);
|
|
260
|
+
expect(mocks.readIconAssetForAuthorizedReference).not.toHaveBeenCalled();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("denies an org ID that disagrees with the mapped domain", async () => {
|
|
264
|
+
mocks.verifyA2AToken.mockResolvedValue({
|
|
265
|
+
...verified("private-icon:read", { assetId: id }),
|
|
266
|
+
orgDomain: "example.test",
|
|
267
|
+
});
|
|
268
|
+
mocks.resolveOrgByDomain.mockResolvedValue({ orgId: "different-org" });
|
|
269
|
+
const response = await createPrivateIconAssetsHandler()(event("GET"));
|
|
270
|
+
expect(response.status).toBe(401);
|
|
271
|
+
expect(mocks.readIconAssetForAuthorizedReference).not.toHaveBeenCalled();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("returns 404 when an asset is absent from the verified org", async () => {
|
|
275
|
+
mocks.verifyA2AToken.mockResolvedValue(
|
|
276
|
+
verified("private-icon:read", { assetId: id }),
|
|
277
|
+
);
|
|
278
|
+
mocks.readIconAssetForAuthorizedReference.mockResolvedValue(null);
|
|
279
|
+
const response = await createPrivateIconAssetsHandler()(event("GET"));
|
|
280
|
+
expect(response.status).toBe(404);
|
|
281
|
+
expect(mocks.readIconAssetForAuthorizedReference).toHaveBeenCalledWith(id, {
|
|
282
|
+
orgId: "dispatch-org",
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("serves bytes with private and SVG-safe headers", async () => {
|
|
287
|
+
mocks.verifyA2AToken.mockResolvedValue(
|
|
288
|
+
verified("private-icon:read", { assetId: id }),
|
|
289
|
+
);
|
|
290
|
+
mocks.readIconAssetForAuthorizedReference.mockResolvedValue({
|
|
291
|
+
data: new TextEncoder().encode("<svg></svg>"),
|
|
292
|
+
mimeType: "image/svg+xml",
|
|
293
|
+
});
|
|
294
|
+
const response = await createPrivateIconAssetsHandler()(event("GET"));
|
|
295
|
+
expect(response.status).toBe(200);
|
|
296
|
+
expect(await response.text()).toBe("<svg></svg>");
|
|
297
|
+
expect(response.headers.get("cache-control")).toBe("private, no-store");
|
|
298
|
+
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
299
|
+
expect(response.headers.get("content-security-policy")).toContain(
|
|
300
|
+
"sandbox",
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("checks owner membership before confirming an ID", async () => {
|
|
305
|
+
mocks.verifyA2AToken.mockResolvedValue(
|
|
306
|
+
verified("private-icon:verify-owner"),
|
|
307
|
+
);
|
|
308
|
+
mocks.isOrgMember.mockResolvedValue(false);
|
|
309
|
+
expect((await createPrivateIconAssetsHandler()(event("HEAD"))).status).toBe(
|
|
310
|
+
401,
|
|
311
|
+
);
|
|
312
|
+
expect(mocks.getIconAsset).not.toHaveBeenCalled();
|
|
313
|
+
mocks.isOrgMember.mockResolvedValue(true);
|
|
314
|
+
mocks.getIconAsset.mockResolvedValue(null);
|
|
315
|
+
expect((await createPrivateIconAssetsHandler()(event("HEAD"))).status).toBe(
|
|
316
|
+
404,
|
|
317
|
+
);
|
|
318
|
+
mocks.getIconAsset.mockResolvedValue({ id });
|
|
319
|
+
expect((await createPrivateIconAssetsHandler()(event("HEAD"))).status).toBe(
|
|
320
|
+
200,
|
|
321
|
+
);
|
|
322
|
+
expect(mocks.getIconAsset).toHaveBeenCalledWith(id, {
|
|
323
|
+
ownerEmail: "owner@example.test",
|
|
324
|
+
orgId: "dispatch-org",
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("uploads a bounded multipart icon for a verified member", async () => {
|
|
329
|
+
mocks.verifyA2AToken.mockResolvedValue(verified("private-icon:upload"));
|
|
330
|
+
mocks.putIconAsset.mockResolvedValue({
|
|
331
|
+
id,
|
|
332
|
+
filename: "mark.png",
|
|
333
|
+
mimeType: "image/png",
|
|
334
|
+
size: 4,
|
|
335
|
+
});
|
|
336
|
+
const url = new URL("https://dispatch.example.test/");
|
|
337
|
+
const form = new FormData();
|
|
338
|
+
form.set(
|
|
339
|
+
"file",
|
|
340
|
+
new File([Uint8Array.of(137, 80, 78, 71)], "mark.png", {
|
|
341
|
+
type: "image/png",
|
|
342
|
+
}),
|
|
343
|
+
);
|
|
344
|
+
const encoded = new Request(url, { method: "POST", body: form });
|
|
345
|
+
const body = await encoded.arrayBuffer();
|
|
346
|
+
const headers = new Headers(encoded.headers);
|
|
347
|
+
headers.set("authorization", "Bearer example-signed-token");
|
|
348
|
+
headers.set("content-length", String(body.byteLength));
|
|
349
|
+
const response = await createPrivateIconAssetsHandler()({
|
|
350
|
+
url,
|
|
351
|
+
req: new Request(url, { method: "POST", headers, body }),
|
|
352
|
+
});
|
|
353
|
+
expect(response.status).toBe(201);
|
|
354
|
+
expect(await response.json()).toMatchObject({
|
|
355
|
+
id,
|
|
356
|
+
filename: "mark.png",
|
|
357
|
+
contentType: "image/png",
|
|
358
|
+
byteLength: 4,
|
|
359
|
+
});
|
|
360
|
+
expect(mocks.putIconAsset).toHaveBeenCalledWith(
|
|
361
|
+
expect.objectContaining({
|
|
362
|
+
ownerEmail: "owner@example.test",
|
|
363
|
+
orgId: "dispatch-org",
|
|
364
|
+
filename: "mark.png",
|
|
365
|
+
}),
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("rejects an understated content length before parsing or writing", async () => {
|
|
370
|
+
mocks.verifyA2AToken.mockResolvedValue(verified("private-icon:upload"));
|
|
371
|
+
const cancel = vi.fn();
|
|
372
|
+
const url = new URL("https://dispatch.example.test/");
|
|
373
|
+
const req = new Request(url, {
|
|
374
|
+
method: "POST",
|
|
375
|
+
body: new ReadableStream({
|
|
376
|
+
start(controller) {
|
|
377
|
+
controller.enqueue(new Uint8Array(MAX_ICON_MULTIPART_BYTES + 1));
|
|
378
|
+
},
|
|
379
|
+
cancel,
|
|
380
|
+
}),
|
|
381
|
+
duplex: "half",
|
|
382
|
+
headers: {
|
|
383
|
+
authorization: "Bearer example-signed-token",
|
|
384
|
+
"content-type": "multipart/form-data; boundary=example-boundary",
|
|
385
|
+
"content-length": "1",
|
|
386
|
+
},
|
|
387
|
+
} as RequestInit);
|
|
388
|
+
const response = await createPrivateIconAssetsHandler()({ url, req });
|
|
389
|
+
expect(response.status).toBe(413);
|
|
390
|
+
expect(cancel).toHaveBeenCalledOnce();
|
|
391
|
+
expect(mocks.putIconAsset).not.toHaveBeenCalled();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("retains the content-length requirement for Dispatch uploads", async () => {
|
|
395
|
+
mocks.verifyA2AToken.mockResolvedValue(verified("private-icon:upload"));
|
|
396
|
+
const url = new URL("https://dispatch.example.test/");
|
|
397
|
+
const form = new FormData();
|
|
398
|
+
form.set(
|
|
399
|
+
"file",
|
|
400
|
+
new File([Uint8Array.of(1, 2)], "logo.png", { type: "image/png" }),
|
|
401
|
+
);
|
|
402
|
+
const response = await createPrivateIconAssetsHandler()({
|
|
403
|
+
url,
|
|
404
|
+
req: new Request(url, {
|
|
405
|
+
method: "POST",
|
|
406
|
+
body: form,
|
|
407
|
+
headers: { authorization: "Bearer example-signed-token" },
|
|
408
|
+
}),
|
|
409
|
+
});
|
|
410
|
+
expect(response.status).toBe(411);
|
|
411
|
+
expect(mocks.putIconAsset).not.toHaveBeenCalled();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("limits the owner library to safe metadata", async () => {
|
|
415
|
+
mocks.verifyA2AToken.mockResolvedValue(verified("private-icon:list"));
|
|
416
|
+
mocks.listIconAssets.mockResolvedValue([
|
|
417
|
+
{
|
|
418
|
+
id,
|
|
419
|
+
mimeType: "image/png",
|
|
420
|
+
size: 4,
|
|
421
|
+
createdAt: 123,
|
|
422
|
+
blobHandleJson: "private-handle",
|
|
423
|
+
ownerEmail: "owner@example.test",
|
|
424
|
+
},
|
|
425
|
+
]);
|
|
426
|
+
const response = await createPrivateIconAssetsHandler()(event("GET", "/"));
|
|
427
|
+
expect(response.status).toBe(200);
|
|
428
|
+
expect(await response.json()).toEqual({
|
|
429
|
+
assets: [{ id, mimeType: "image/png", size: 4, createdAt: 123 }],
|
|
430
|
+
});
|
|
431
|
+
expect(mocks.listIconAssets).toHaveBeenCalledWith({
|
|
432
|
+
ownerEmail: "owner@example.test",
|
|
433
|
+
orgId: "dispatch-org",
|
|
434
|
+
limit: 100,
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("accepts a signed service JWT only for the private-icon audience", async () => {
|
|
439
|
+
vi.stubEnv("A2A_SECRET", "example-dispatch-shared-secret");
|
|
440
|
+
vi.stubEnv("APP_URL", "https://dispatch.example.test");
|
|
441
|
+
const { verifyA2AToken } = await vi.importActual<
|
|
442
|
+
typeof import("@agent-native/core/a2a")
|
|
443
|
+
>("@agent-native/core/a2a");
|
|
444
|
+
mocks.verifyA2AToken.mockImplementation(verifyA2AToken);
|
|
445
|
+
|
|
446
|
+
const token = (aud: string) => {
|
|
447
|
+
const now = Math.floor(Date.now() / 1000);
|
|
448
|
+
const head = Buffer.from(
|
|
449
|
+
JSON.stringify({ alg: "HS256", typ: "JWT" }),
|
|
450
|
+
).toString("base64url");
|
|
451
|
+
const body = Buffer.from(
|
|
452
|
+
JSON.stringify({
|
|
453
|
+
sub: "owner@example.test",
|
|
454
|
+
iss: "https://content.example.test",
|
|
455
|
+
aud,
|
|
456
|
+
scope: "private-icon:read",
|
|
457
|
+
org_id: "dispatch-org",
|
|
458
|
+
asset_id: id,
|
|
459
|
+
iat: now,
|
|
460
|
+
exp: now + 60,
|
|
461
|
+
}),
|
|
462
|
+
).toString("base64url");
|
|
463
|
+
const unsigned = `${head}.${body}`;
|
|
464
|
+
const signature = createHmac("sha256", "example-dispatch-shared-secret")
|
|
465
|
+
.update(unsigned)
|
|
466
|
+
.digest("base64url");
|
|
467
|
+
return `${unsigned}.${signature}`;
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const withToken = (value: string) => {
|
|
471
|
+
const requestEvent = event("GET");
|
|
472
|
+
requestEvent.req = new Request(requestEvent.url, {
|
|
473
|
+
headers: { authorization: `Bearer ${value}` },
|
|
474
|
+
});
|
|
475
|
+
return requestEvent;
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
const handler = createPrivateIconAssetsHandler();
|
|
480
|
+
expect(
|
|
481
|
+
(
|
|
482
|
+
await handler(
|
|
483
|
+
withToken(token("https://other.example.test/private-icon")),
|
|
484
|
+
)
|
|
485
|
+
).status,
|
|
486
|
+
).toBe(401);
|
|
487
|
+
expect(
|
|
488
|
+
(
|
|
489
|
+
await handler(
|
|
490
|
+
withToken(token("https://dispatch.example.test/private-icon")),
|
|
491
|
+
)
|
|
492
|
+
).status,
|
|
493
|
+
).toBe(200);
|
|
494
|
+
} finally {
|
|
495
|
+
vi.unstubAllEnvs();
|
|
496
|
+
}
|
|
497
|
+
}, 20_000);
|
|
498
|
+
});
|