@agent-native/dispatch 0.8.19 → 0.8.23

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 (49) hide show
  1. package/README.md +22 -2
  2. package/dist/actions/start-workspace-app-creation.js +1 -1
  3. package/dist/actions/start-workspace-app-creation.js.map +1 -1
  4. package/dist/components/create-app-popover.js +3 -3
  5. package/dist/components/create-app-popover.js.map +1 -1
  6. package/dist/components/layout/Layout.d.ts.map +1 -1
  7. package/dist/components/layout/Layout.js +66 -8
  8. package/dist/components/layout/Layout.js.map +1 -1
  9. package/dist/routes/pages/apps.d.ts.map +1 -1
  10. package/dist/routes/pages/apps.js +3 -1
  11. package/dist/routes/pages/apps.js.map +1 -1
  12. package/dist/routes/pages/chat.d.ts +21 -2
  13. package/dist/routes/pages/chat.d.ts.map +1 -1
  14. package/dist/routes/pages/chat.js +12 -3
  15. package/dist/routes/pages/chat.js.map +1 -1
  16. package/dist/routes/pages/overview.d.ts +21 -2
  17. package/dist/routes/pages/overview.d.ts.map +1 -1
  18. package/dist/routes/pages/overview.js +13 -4
  19. package/dist/routes/pages/overview.js.map +1 -1
  20. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  21. package/dist/server/lib/app-creation-store.js +18 -0
  22. package/dist/server/lib/app-creation-store.js.map +1 -1
  23. package/dist/server/lib/dispatch-integrations.d.ts.map +1 -1
  24. package/dist/server/lib/dispatch-integrations.js +27 -3
  25. package/dist/server/lib/dispatch-integrations.js.map +1 -1
  26. package/dist/server/lib/thread-link-preview.d.ts +24 -0
  27. package/dist/server/lib/thread-link-preview.d.ts.map +1 -0
  28. package/dist/server/lib/thread-link-preview.js +176 -0
  29. package/dist/server/lib/thread-link-preview.js.map +1 -0
  30. package/dist/server/plugins/agent-chat.js +1 -1
  31. package/dist/server/plugins/agent-chat.js.map +1 -1
  32. package/dist/server/plugins/integrations.js +2 -2
  33. package/dist/server/plugins/integrations.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/actions/start-workspace-app-creation.ts +1 -1
  36. package/src/components/create-app-popover.tsx +3 -3
  37. package/src/components/layout/Layout.tsx +133 -14
  38. package/src/routes/pages/apps.tsx +4 -0
  39. package/src/routes/pages/chat.tsx +20 -3
  40. package/src/routes/pages/overview.tsx +21 -8
  41. package/src/server/lib/app-creation-store.spec.ts +15 -0
  42. package/src/server/lib/app-creation-store.ts +18 -0
  43. package/src/server/lib/dispatch-integrations.spec.ts +69 -0
  44. package/src/server/lib/dispatch-integrations.ts +26 -3
  45. package/src/server/lib/thread-link-preview.spec.ts +129 -0
  46. package/src/server/lib/thread-link-preview.ts +187 -0
  47. package/src/server/plugins/agent-chat.ts +1 -1
  48. package/src/server/plugins/integrations.ts +2 -2
  49. package/src/styles/dispatch.css +4 -4
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  useEffect,
3
3
  useMemo,
4
+ useRef,
4
5
  useState,
5
6
  type ComponentType,
7
+ type FormEvent,
6
8
  type ReactNode,
7
9
  } from "react";
8
10
  import { NavLink, useLocation, useNavigate } from "react-router";
@@ -25,6 +27,8 @@ import {
25
27
  IconBrandTelegram,
26
28
  IconKey,
27
29
  IconChevronDown,
30
+ IconDots,
31
+ IconEdit,
28
32
  IconLayersSubtract,
29
33
  IconMessageQuestion,
30
34
  IconMessages,
@@ -38,6 +42,13 @@ import {
38
42
  IconUsersGroup,
39
43
  } from "@tabler/icons-react";
40
44
  import { cn } from "@/lib/utils";
45
+ import {
46
+ DropdownMenu,
47
+ DropdownMenuContent,
48
+ DropdownMenuItem,
49
+ DropdownMenuTrigger,
50
+ } from "@/components/ui/dropdown-menu";
51
+ import { Input } from "@/components/ui/input";
41
52
  import {
42
53
  Sheet,
43
54
  SheetContent,
@@ -290,6 +301,14 @@ function threadTitle(thread: ChatThreadSummary) {
290
301
  return thread.title || thread.preview || "New chat";
291
302
  }
292
303
 
304
+ function threadUpdatedAt(thread: ChatThreadSummary) {
305
+ return Number.isFinite(thread.updatedAt)
306
+ ? thread.updatedAt
307
+ : Number.isFinite(thread.createdAt)
308
+ ? thread.createdAt
309
+ : 0;
310
+ }
311
+
293
312
  function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
294
313
  const navigate = useNavigate();
295
314
  const {
@@ -297,8 +316,13 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
297
316
  activeThreadId,
298
317
  createThread,
299
318
  switchThread,
319
+ renameThread,
300
320
  refreshThreads,
301
321
  } = useChatThreads(undefined, undefined, undefined, { autoCreate: false });
322
+ const [renamingThreadId, setRenamingThreadId] = useState<string | null>(null);
323
+ const [renameDraft, setRenameDraft] = useState("");
324
+ const renameInputRef = useRef<HTMLInputElement | null>(null);
325
+ const committingRenameRef = useRef(false);
302
326
 
303
327
  const visibleThreads = useMemo(
304
328
  () =>
@@ -306,7 +330,7 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
306
330
  .filter(
307
331
  (thread) => thread.messageCount > 0 || thread.id === activeThreadId,
308
332
  )
309
- .sort((a, b) => b.updatedAt - a.updatedAt)
333
+ .sort((a, b) => threadUpdatedAt(b) - threadUpdatedAt(a))
310
334
  .slice(0, 8),
311
335
  [activeThreadId, threads],
312
336
  );
@@ -330,6 +354,14 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
330
354
  };
331
355
  }, [refreshThreads]);
332
356
 
357
+ useEffect(() => {
358
+ if (!renamingThreadId) return;
359
+ requestAnimationFrame(() => {
360
+ renameInputRef.current?.focus();
361
+ renameInputRef.current?.select();
362
+ });
363
+ }, [renamingThreadId]);
364
+
333
365
  function openThread(threadId: string, options?: { isNew?: boolean }) {
334
366
  switchThread(threadId);
335
367
  navigate(dispatchNavLinkTarget("/chat"));
@@ -348,6 +380,35 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
348
380
  if (threadId) openThread(threadId, { isNew: true });
349
381
  }
350
382
 
383
+ function startRenameThread(thread: ChatThreadSummary) {
384
+ committingRenameRef.current = false;
385
+ setRenameDraft(threadTitle(thread));
386
+ setRenamingThreadId(thread.id);
387
+ }
388
+
389
+ function cancelRenameThread() {
390
+ committingRenameRef.current = true;
391
+ setRenamingThreadId(null);
392
+ setRenameDraft("");
393
+ }
394
+
395
+ async function commitRenameThread() {
396
+ if (committingRenameRef.current) return;
397
+ const threadId = renamingThreadId;
398
+ const title = renameDraft.trim();
399
+ if (!threadId) return;
400
+ committingRenameRef.current = true;
401
+ setRenamingThreadId(null);
402
+ setRenameDraft("");
403
+ if (title) await renameThread(threadId, title);
404
+ committingRenameRef.current = false;
405
+ }
406
+
407
+ function handleRenameSubmit(event: FormEvent<HTMLFormElement>) {
408
+ event.preventDefault();
409
+ void commitRenameThread();
410
+ }
411
+
351
412
  return (
352
413
  <div className="mt-2 border-l border-sidebar-border/70 pl-3">
353
414
  <div className="mb-1 flex h-7 items-center gap-2 pr-1">
@@ -372,25 +433,82 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
372
433
  {visibleThreads.length > 0 ? (
373
434
  visibleThreads.map((thread) => {
374
435
  const isActive = thread.id === activeThreadId;
436
+ const isRenaming = thread.id === renamingThreadId;
375
437
  return (
376
- <button
438
+ <div
377
439
  key={thread.id}
378
- type="button"
379
- onClick={() => openThread(thread.id)}
380
440
  className={cn(
381
- "flex h-8 min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 text-left text-sm transition-colors",
441
+ "group flex h-8 min-w-0 items-center rounded-md text-sm transition-colors",
382
442
  isActive
383
443
  ? "bg-sidebar-accent text-sidebar-accent-foreground"
384
444
  : "text-sidebar-foreground/80 hover:bg-sidebar-accent/65 hover:text-sidebar-accent-foreground",
385
445
  )}
386
446
  >
387
- <span className="min-w-0 flex-1 truncate">
388
- {threadTitle(thread)}
389
- </span>
390
- <span className="shrink-0 text-[11px] text-sidebar-foreground/50">
391
- {isActive ? "" : formatThreadAge(thread.updatedAt)}
392
- </span>
393
- </button>
447
+ {isRenaming ? (
448
+ <form
449
+ onSubmit={handleRenameSubmit}
450
+ className="flex h-full min-w-0 flex-1 items-center px-1.5"
451
+ >
452
+ <Input
453
+ ref={renameInputRef}
454
+ value={renameDraft}
455
+ onChange={(event) => setRenameDraft(event.target.value)}
456
+ onBlur={() => void commitRenameThread()}
457
+ onKeyDown={(event) => {
458
+ if (event.key === "Escape") {
459
+ event.preventDefault();
460
+ cancelRenameThread();
461
+ }
462
+ }}
463
+ maxLength={160}
464
+ aria-label={`Rename ${threadTitle(thread)}`}
465
+ className="h-6 min-w-0 rounded-sm border-sidebar-border bg-background px-1.5 text-xs"
466
+ />
467
+ </form>
468
+ ) : (
469
+ <>
470
+ <button
471
+ type="button"
472
+ onClick={() => openThread(thread.id)}
473
+ className="flex h-full min-w-0 flex-1 cursor-pointer items-center px-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
474
+ >
475
+ <span className="min-w-0 flex-1 truncate">
476
+ {threadTitle(thread)}
477
+ </span>
478
+ </button>
479
+ <div className="relative flex size-7 shrink-0 items-center justify-end pr-1">
480
+ <span className="text-[11px] text-sidebar-foreground/50 transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
481
+ {isActive
482
+ ? ""
483
+ : formatThreadAge(threadUpdatedAt(thread))}
484
+ </span>
485
+ <DropdownMenu>
486
+ <DropdownMenuTrigger asChild>
487
+ <button
488
+ type="button"
489
+ aria-label={`Chat options for ${threadTitle(thread)}`}
490
+ className="absolute right-1 flex size-6 cursor-pointer items-center justify-center rounded-md text-sidebar-foreground/65 opacity-0 transition-opacity hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100 group-focus-within:opacity-100 data-[state=open]:opacity-100"
491
+ >
492
+ <IconDots className="size-4" />
493
+ </button>
494
+ </DropdownMenuTrigger>
495
+ <DropdownMenuContent
496
+ align="end"
497
+ side="right"
498
+ sideOffset={6}
499
+ >
500
+ <DropdownMenuItem
501
+ onSelect={() => startRenameThread(thread)}
502
+ >
503
+ <IconEdit className="size-4" />
504
+ Rename chat
505
+ </DropdownMenuItem>
506
+ </DropdownMenuContent>
507
+ </DropdownMenu>
508
+ </div>
509
+ </>
510
+ )}
511
+ </div>
394
512
  );
395
513
  })
396
514
  ) : (
@@ -438,13 +556,14 @@ export function NavContent({
438
556
 
439
557
  const renderNavItem = (item: DispatchNavItem) => {
440
558
  const Icon = item.icon;
559
+ const itemMatchesLocalPath = navItemMatchesPath(item, localPathname);
441
560
  return (
442
561
  <li key={item.id}>
443
562
  <NavLink
444
563
  to={dispatchNavLinkTarget(item.to)}
445
564
  onClick={onNavigate}
446
565
  className={({ isActive }) => {
447
- const active = isActive || navItemMatchesPath(item, localPathname);
566
+ const active = isActive || itemMatchesLocalPath;
448
567
  return cn(
449
568
  "flex h-8 w-full items-center gap-2 rounded-md px-2 text-sm",
450
569
  active
@@ -460,7 +579,7 @@ export function NavContent({
460
579
  )}
461
580
  <span className="truncate">{item.label}</span>
462
581
  </NavLink>
463
- {item.id === "chat" ? (
582
+ {item.id === "chat" && itemMatchesLocalPath ? (
464
583
  <DispatchChatsSection onNavigate={onNavigate} />
465
584
  ) : null}
466
585
  </li>
@@ -2,6 +2,7 @@ import { useState } from "react";
2
2
  import { useActionMutation, useActionQuery } from "@agent-native/core/client";
3
3
  import {
4
4
  IconApps,
5
+ IconBrain,
5
6
  IconBrush,
6
7
  IconCalendarMonth,
7
8
  IconChartBar,
@@ -11,6 +12,7 @@ import {
11
12
  IconFileText,
12
13
  IconLoader2,
13
14
  IconMail,
15
+ IconPhoto,
14
16
  IconPlus,
15
17
  IconPresentation,
16
18
  IconScreenShare,
@@ -58,6 +60,8 @@ const TEMPLATE_ICONS: Record<string, typeof IconMail> = {
58
60
  FileText: IconFileText,
59
61
  Presentation: IconPresentation,
60
62
  ScreenShare: IconScreenShare,
63
+ Brain: IconBrain,
64
+ Photo: IconPhoto,
61
65
  ChartBar: IconChartBar,
62
66
  ClipboardList: IconClipboardList,
63
67
  Brush: IconBrush,
@@ -1,7 +1,15 @@
1
1
  import { useEffect, useRef } from "react";
2
- import { useLocation, useNavigate } from "react-router";
2
+ import {
3
+ useLocation,
4
+ useNavigate,
5
+ type LoaderFunctionArgs,
6
+ } from "react-router";
3
7
  import { AgentChatSurface } from "@agent-native/core/client";
4
8
  import { submitOverviewPrompt } from "@/lib/overview-chat";
9
+ import {
10
+ buildThreadLinkPreviewMeta,
11
+ loadThreadLinkPreview,
12
+ } from "@/server/lib/thread-link-preview";
5
13
 
6
14
  interface DispatchChatLocationState {
7
15
  dispatchPrompt?: {
@@ -15,8 +23,17 @@ interface DispatchChatLocationState {
15
23
  };
16
24
  }
17
25
 
18
- export function meta() {
19
- return [{ title: "Chat — Dispatch" }];
26
+ export async function loader({ request }: LoaderFunctionArgs) {
27
+ const threadId = new URL(request.url).searchParams.get("thread");
28
+ return {
29
+ threadPreview: await loadThreadLinkPreview(threadId),
30
+ };
31
+ }
32
+
33
+ export function meta({ data }: { data?: Awaited<ReturnType<typeof loader>> }) {
34
+ return data?.threadPreview
35
+ ? buildThreadLinkPreviewMeta(data.threadPreview)
36
+ : [{ title: "Chat — Dispatch" }];
20
37
  }
21
38
 
22
39
  export default function ChatRoute() {
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useState } from "react";
2
- import { Link, useNavigate } from "react-router";
2
+ import { Link, useNavigate, type LoaderFunctionArgs } from "react-router";
3
3
  import {
4
4
  PromptComposer,
5
5
  useActionQuery,
@@ -34,6 +34,10 @@ import {
34
34
  TooltipTrigger,
35
35
  } from "@/components/ui/tooltip";
36
36
  import { submitOverviewPrompt } from "@/lib/overview-chat";
37
+ import {
38
+ buildThreadLinkPreviewMeta,
39
+ loadThreadLinkPreview,
40
+ } from "@/server/lib/thread-link-preview";
37
41
  import type { WorkspaceAppSummary } from "@/lib/workspace-apps";
38
42
 
39
43
  interface IntegrationStatus {
@@ -395,18 +399,18 @@ function StatCard({
395
399
  cta?: React.ReactNode;
396
400
  }) {
397
401
  return (
398
- <div className="rounded-2xl border bg-card p-5">
402
+ <div className="min-w-0 rounded-2xl border bg-card p-5">
399
403
  <div className="flex items-start justify-between gap-3">
400
404
  <div className="min-w-0">
401
- <div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
402
- <span>{label}</span>
405
+ <div className="flex items-center gap-1.5 text-sm font-medium leading-snug text-foreground">
406
+ <span className="min-w-0">{label}</span>
403
407
  <HelpTooltip content={help} />
404
408
  </div>
405
409
  <div className="mt-3 text-3xl font-semibold text-foreground">
406
410
  {value}
407
411
  </div>
408
412
  </div>
409
- <div className="rounded-xl border bg-muted/30 p-3 text-muted-foreground">
413
+ <div className="shrink-0 rounded-xl border bg-muted/30 p-3 text-muted-foreground">
410
414
  <Icon size={18} />
411
415
  </div>
412
416
  </div>
@@ -471,8 +475,17 @@ function StepRow({ step }: { step: ChecklistStep }) {
471
475
  );
472
476
  }
473
477
 
474
- export function meta() {
475
- return [{ title: "Overview — Dispatch" }];
478
+ export async function loader({ request }: LoaderFunctionArgs) {
479
+ const threadId = new URL(request.url).searchParams.get("thread");
480
+ return {
481
+ threadPreview: await loadThreadLinkPreview(threadId),
482
+ };
483
+ }
484
+
485
+ export function meta({ data }: { data?: Awaited<ReturnType<typeof loader>> }) {
486
+ return data?.threadPreview
487
+ ? buildThreadLinkPreviewMeta(data.threadPreview)
488
+ : [{ title: "Overview — Dispatch" }];
476
489
  }
477
490
 
478
491
  export default function OverviewRoute() {
@@ -636,7 +649,7 @@ export default function OverviewRoute() {
636
649
  <IconActivity size={16} className="text-muted-foreground" />
637
650
  <h2 className="text-sm font-semibold text-foreground">At a glance</h2>
638
651
  </div>
639
- <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
652
+ <div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,13rem),1fr))] gap-4">
640
653
  <StatCard
641
654
  label="Vault secrets"
642
655
  help="Credentials stored in the workspace vault."
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { runWithRequestContext } from "@agent-native/core/server";
3
3
  import {
4
4
  generateWorkspaceAppDescription,
5
+ listAvailableWorkspaceTemplates,
5
6
  listWorkspaceApps,
6
7
  updateWorkspaceAppMetadata,
7
8
  } from "./app-creation-store.js";
@@ -347,4 +348,18 @@ describe("listWorkspaceApps", () => {
347
348
  ),
348
349
  ).toBe("Tracks customer onboarding risks and handoffs.");
349
350
  });
351
+
352
+ it("offers Brain and Assets as workspace template tiles", async () => {
353
+ stubNoPendingContext();
354
+ stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
355
+
356
+ const templates = await runWithRequestContext(
357
+ { userEmail: "dev@example.test" },
358
+ () => listAvailableWorkspaceTemplates(),
359
+ );
360
+
361
+ expect(templates.map((template) => template.name)).toEqual(
362
+ expect.arrayContaining(["brain", "assets"]),
363
+ );
364
+ });
350
365
  });
@@ -1293,6 +1293,24 @@ const ADDABLE_TEMPLATES: AvailableWorkspaceTemplate[] = [
1293
1293
  colorRgb: "98 93 245",
1294
1294
  core: true,
1295
1295
  },
1296
+ {
1297
+ name: "brain",
1298
+ label: "Brain",
1299
+ hint: "Cited company knowledge from Slack, meetings, transcripts, and decisions",
1300
+ icon: "Brain",
1301
+ color: "#8B5CF6",
1302
+ colorRgb: "139 92 246",
1303
+ core: true,
1304
+ },
1305
+ {
1306
+ name: "assets",
1307
+ label: "Assets",
1308
+ hint: "Upload, organize, search, and generate on-brand images and videos",
1309
+ icon: "Photo",
1310
+ color: "#0F766E",
1311
+ colorRgb: "15 118 110",
1312
+ core: true,
1313
+ },
1296
1314
  {
1297
1315
  name: "analytics",
1298
1316
  label: "Analytics",
@@ -38,6 +38,21 @@ function slackIncoming(
38
38
  };
39
39
  }
40
40
 
41
+ function emailIncoming(
42
+ overrides: Partial<IncomingMessage> = {},
43
+ ): IncomingMessage {
44
+ return {
45
+ platform: "email",
46
+ externalThreadId: "victim@member.test::<root@member.test>",
47
+ text: "transfer everything",
48
+ senderId: "victim@member.test",
49
+ senderName: "Victim",
50
+ platformContext: { from: "victim@member.test" },
51
+ timestamp: 1,
52
+ ...overrides,
53
+ };
54
+ }
55
+
41
56
  beforeEach(() => {
42
57
  mocks.resolveLinkedOwner.mockResolvedValue(null);
43
58
  mocks.consumeLinkToken.mockResolvedValue("owner@example.test");
@@ -113,4 +128,58 @@ describe("resolveDispatchOwner", () => {
113
128
  "default@example.test",
114
129
  );
115
130
  });
131
+
132
+ it("does NOT impersonate an org member from an unverified (spoofed) email From", async () => {
133
+ // Attacker spoofs From: victim@member.test, which IS a real org member —
134
+ // but the message is unverified (no DKIM/SPF pass). Must fall through to
135
+ // the synthetic, credential-less owner, NOT the victim's identity.
136
+ mocks.resolveOrgIdForEmail.mockResolvedValue("org_123");
137
+
138
+ const owner = await resolveDispatchOwner(
139
+ emailIncoming({ senderVerified: false }),
140
+ );
141
+
142
+ expect(owner).not.toBe("victim@member.test");
143
+ expect(owner).toMatch(/@integration\.local$/);
144
+ });
145
+
146
+ it("does NOT impersonate when sender is verified but not an org member", async () => {
147
+ mocks.resolveOrgIdForEmail.mockResolvedValue(null);
148
+
149
+ const owner = await resolveDispatchOwner(
150
+ emailIncoming({
151
+ senderId: "stranger@outside.test",
152
+ platformContext: { from: "stranger@outside.test" },
153
+ senderVerified: true,
154
+ }),
155
+ );
156
+
157
+ expect(owner).not.toBe("stranger@outside.test");
158
+ expect(owner).toMatch(/@integration\.local$/);
159
+ });
160
+
161
+ it("uses the email sender as owner when verified AND an org member", async () => {
162
+ mocks.resolveOrgIdForEmail.mockResolvedValue("org_123");
163
+
164
+ await expect(
165
+ resolveDispatchOwner(emailIncoming({ senderVerified: true })),
166
+ ).resolves.toBe("victim@member.test");
167
+ });
168
+
169
+ it("honors a linked identity for email regardless of verification", async () => {
170
+ mocks.resolveLinkedOwner.mockResolvedValueOnce("linked@member.test");
171
+
172
+ await expect(
173
+ resolveDispatchOwner(emailIncoming({ senderVerified: false })),
174
+ ).resolves.toBe("linked@member.test");
175
+ expect(mocks.resolveOrgIdForEmail).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it("restores legacy trust-From behavior under the escape hatch", async () => {
179
+ vi.stubEnv("DISPATCH_TRUST_UNVERIFIED_EMAIL_SENDER", "1");
180
+
181
+ await expect(
182
+ resolveDispatchOwner(emailIncoming({ senderVerified: false })),
183
+ ).resolves.toBe("victim@member.test");
184
+ });
116
185
  });
@@ -164,14 +164,37 @@ export async function resolveDispatchOwner(
164
164
  });
165
165
  if (owner) return owner;
166
166
 
167
- // For email, the sender's email address is already a natural identity.
168
- // If the senderId looks like an email address, use it directly as the owner.
167
+ // For email, the sender's `From:` address is attacker-settable: SMTP lets
168
+ // anyone claim any From, and our inbound webhook secret only authenticates
169
+ // the provider→app hop, not the original sender. So we must NOT grant a
170
+ // real user's identity (their API keys, org secrets, personal
171
+ // instructions, ownable data) off the bare From. Mirror the Slack gate:
172
+ // only return the sender email as the acting owner when BOTH
173
+ // (a) the message is DKIM/SPF-verified for the From domain, AND
174
+ // (b) that email maps to a real org member.
175
+ // Otherwise fall through to the synthetic, credential-less fallback owner.
176
+ // (A linked identity, handled by resolveLinkedOwner above, remains an
177
+ // always-allowed way to bind an address regardless of verification.)
178
+ //
179
+ // Escape hatch: set DISPATCH_TRUST_UNVERIFIED_EMAIL_SENDER=1 to restore
180
+ // the legacy "trust the From header" behavior. OFF by default; only use
181
+ // this if you fully control the inbound mail path and accept that a
182
+ // spoofed From can act as any org member. See FINDING 3 (inbound-email
183
+ // impersonation) in the webhook security audit.
169
184
  if (
170
185
  incoming.platform === "email" &&
171
186
  incoming.senderId &&
172
187
  incoming.senderId.includes("@")
173
188
  ) {
174
- return incoming.senderId;
189
+ if (process.env.DISPATCH_TRUST_UNVERIFIED_EMAIL_SENDER === "1") {
190
+ return incoming.senderId;
191
+ }
192
+ if (incoming.senderVerified) {
193
+ const orgId = await resolveOrgIdForEmail(incoming.senderId);
194
+ if (orgId) return incoming.senderId;
195
+ }
196
+ // Unverified or not an org member — do not impersonate. Fall through to
197
+ // the synthetic fallback owner below.
175
198
  }
176
199
 
177
200
  // Slack gives us a user id in the event payload. Resolve it to a verified
@@ -0,0 +1,129 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { ChatThread } from "@agent-native/core/server";
3
+
4
+ const getRequestContextMock = vi.hoisted(() => vi.fn());
5
+ const getThreadMock = vi.hoisted(() => vi.fn());
6
+
7
+ vi.mock("@agent-native/core/server", () => ({
8
+ getRequestContext: getRequestContextMock,
9
+ getThread: getThreadMock,
10
+ }));
11
+
12
+ import {
13
+ extractThreadPreviewImageUrl,
14
+ loadThreadLinkPreview,
15
+ } from "./thread-link-preview";
16
+
17
+ function threadDataWithResult(toolName: string, result: unknown) {
18
+ return JSON.stringify({
19
+ messages: [
20
+ {
21
+ message: {
22
+ role: "assistant",
23
+ content: [
24
+ {
25
+ type: "tool-call",
26
+ toolName,
27
+ result:
28
+ typeof result === "string" ? result : JSON.stringify(result),
29
+ },
30
+ ],
31
+ },
32
+ parentId: null,
33
+ },
34
+ ],
35
+ });
36
+ }
37
+
38
+ function previewThread(overrides: Partial<ChatThread> = {}): ChatThread {
39
+ return {
40
+ id: "thread-1",
41
+ ownerEmail: "owner@example.test",
42
+ title: "Launch image",
43
+ preview: "Generated a launch image",
44
+ threadData: threadDataWithResult("generate-image", {
45
+ previewUrl: "https://cdn.example.com/generated-social.webp",
46
+ }),
47
+ messageCount: 1,
48
+ createdAt: 1,
49
+ updatedAt: 2,
50
+ scope: null,
51
+ pinnedAt: null,
52
+ archivedAt: null,
53
+ ...overrides,
54
+ };
55
+ }
56
+
57
+ beforeEach(() => {
58
+ getRequestContextMock.mockReset();
59
+ getThreadMock.mockReset();
60
+ });
61
+
62
+ describe("thread link preview image extraction", () => {
63
+ it("uses generated image preview URLs from generate-image results", () => {
64
+ expect(
65
+ extractThreadPreviewImageUrl(
66
+ threadDataWithResult("generate-image", {
67
+ url: "https://app.example.com/assets/asset/asset-1",
68
+ previewUrl: "https://cdn.example.com/generated-social.webp",
69
+ thumbnailUrl: "https://cdn.example.com/generated-social-thumb.webp",
70
+ }),
71
+ ),
72
+ ).toBe("https://cdn.example.com/generated-social.webp");
73
+ });
74
+
75
+ it("uses the newest image from batched generation results", () => {
76
+ expect(
77
+ extractThreadPreviewImageUrl(
78
+ threadDataWithResult("generate-image-batch", {
79
+ images: [
80
+ { previewUrl: "https://cdn.example.com/first.png" },
81
+ { previewUrl: "https://cdn.example.com/latest.png" },
82
+ ],
83
+ }),
84
+ ),
85
+ ).toBe("https://cdn.example.com/latest.png");
86
+ });
87
+
88
+ it("ignores asset page URLs that are not image media", () => {
89
+ expect(
90
+ extractThreadPreviewImageUrl(
91
+ threadDataWithResult("generate-image", {
92
+ url: "https://app.example.com/assets/asset/asset-1",
93
+ }),
94
+ ),
95
+ ).toBeNull();
96
+ });
97
+ });
98
+
99
+ describe("thread link preview access", () => {
100
+ it("loads preview metadata for the owning user", async () => {
101
+ getRequestContextMock.mockReturnValue({
102
+ userEmail: "owner@example.test",
103
+ });
104
+ getThreadMock.mockResolvedValue(previewThread());
105
+
106
+ await expect(loadThreadLinkPreview(" thread-1 ")).resolves.toEqual({
107
+ title: "Launch image",
108
+ description: "Generated a launch image",
109
+ imageUrl: "https://cdn.example.com/generated-social.webp",
110
+ });
111
+ expect(getThreadMock).toHaveBeenCalledWith("thread-1");
112
+ });
113
+
114
+ it("does not read thread metadata without an authenticated request context", async () => {
115
+ getRequestContextMock.mockReturnValue(undefined);
116
+
117
+ await expect(loadThreadLinkPreview("thread-1")).resolves.toBeNull();
118
+ expect(getThreadMock).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it("does not emit another user's thread metadata", async () => {
122
+ getRequestContextMock.mockReturnValue({
123
+ userEmail: "viewer@example.test",
124
+ });
125
+ getThreadMock.mockResolvedValue(previewThread());
126
+
127
+ await expect(loadThreadLinkPreview("thread-1")).resolves.toBeNull();
128
+ });
129
+ });