@agent-native/dispatch 0.8.20 → 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.
@@ -0,0 +1,176 @@
1
+ import { getRequestContext, getThread } from "@agent-native/core/server";
2
+ const IMAGE_URL_KEYS = new Set([
3
+ "previewUrl",
4
+ "thumbnailUrl",
5
+ "imageUrl",
6
+ "image",
7
+ "downloadUrl",
8
+ ]);
9
+ const GENERATION_TOOL_NAMES = new Set([
10
+ "generate-image",
11
+ "generate-image-batch",
12
+ "refine-image",
13
+ "rerun-generation-run",
14
+ ]);
15
+ function safeJsonParse(value) {
16
+ try {
17
+ return JSON.parse(value);
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ function cleanUrlCandidate(value) {
24
+ return value
25
+ .trim()
26
+ .replace(/[),.;\]}]+$/g, "")
27
+ .replace(/^["'(<]+/g, "");
28
+ }
29
+ function isAbsoluteHttpUrl(value) {
30
+ try {
31
+ const url = new URL(value);
32
+ return url.protocol === "https:" || url.protocol === "http:";
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ function isImageLikeUrl(value) {
39
+ try {
40
+ const url = new URL(value);
41
+ return (/\.(?:png|jpe?g|webp|gif|avif)(?:$|[?#])/i.test(url.pathname) ||
42
+ /\/api\/assets\/[^/]+\/content(?:$|[?#])/i.test(url.pathname));
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ function validPreviewImageUrl(value, key) {
49
+ if (typeof value !== "string")
50
+ return null;
51
+ const candidate = cleanUrlCandidate(value);
52
+ if (!isAbsoluteHttpUrl(candidate))
53
+ return null;
54
+ if (key && IMAGE_URL_KEYS.has(key))
55
+ return candidate;
56
+ return isImageLikeUrl(candidate) ? candidate : null;
57
+ }
58
+ function imageUrlFromStructuredValue(value) {
59
+ if (!value || typeof value !== "object")
60
+ return null;
61
+ if (Array.isArray(value)) {
62
+ for (let i = value.length - 1; i >= 0; i--) {
63
+ const found = imageUrlFromStructuredValue(value[i]);
64
+ if (found)
65
+ return found;
66
+ }
67
+ return null;
68
+ }
69
+ const record = value;
70
+ for (const key of IMAGE_URL_KEYS) {
71
+ const found = validPreviewImageUrl(record[key], key);
72
+ if (found)
73
+ return found;
74
+ }
75
+ for (const [key, child] of Object.entries(record).reverse()) {
76
+ const direct = validPreviewImageUrl(child, key);
77
+ if (direct)
78
+ return direct;
79
+ if (child && typeof child === "object") {
80
+ const nested = imageUrlFromStructuredValue(child);
81
+ if (nested)
82
+ return nested;
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+ function imageUrlFromText(value) {
88
+ const matches = value.match(/https?:\/\/[^\s<>"']+/g);
89
+ if (!matches)
90
+ return null;
91
+ for (let i = matches.length - 1; i >= 0; i--) {
92
+ const candidate = validPreviewImageUrl(matches[i]);
93
+ if (candidate)
94
+ return candidate;
95
+ }
96
+ return null;
97
+ }
98
+ export function extractThreadPreviewImageUrl(threadData) {
99
+ const parsed = safeJsonParse(threadData);
100
+ if (!parsed || typeof parsed !== "object")
101
+ return null;
102
+ const messages = parsed.messages;
103
+ if (!Array.isArray(messages))
104
+ return null;
105
+ for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex--) {
106
+ const entry = messages[messageIndex];
107
+ const message = entry?.message ?? entry;
108
+ const content = message?.content;
109
+ if (!Array.isArray(content))
110
+ continue;
111
+ for (let partIndex = content.length - 1; partIndex >= 0; partIndex--) {
112
+ const part = content[partIndex];
113
+ const result = typeof part.result === "string" ? part.result : "";
114
+ if (!result.trim())
115
+ continue;
116
+ const toolName = typeof part.toolName === "string" ? part.toolName : "";
117
+ const parsedResult = safeJsonParse(result);
118
+ if (parsedResult && GENERATION_TOOL_NAMES.has(toolName)) {
119
+ const structured = imageUrlFromStructuredValue(parsedResult);
120
+ if (structured)
121
+ return structured;
122
+ }
123
+ const fromText = imageUrlFromText(result);
124
+ if (fromText)
125
+ return fromText;
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+ function previewDescription(thread) {
131
+ const preview = thread.preview.trim();
132
+ if (preview)
133
+ return preview.slice(0, 180);
134
+ return "Open this Agent-Native thread in Dispatch.";
135
+ }
136
+ export async function loadThreadLinkPreview(threadId) {
137
+ const id = threadId?.trim();
138
+ if (!id)
139
+ return null;
140
+ const viewerEmail = getRequestContext()?.userEmail?.trim();
141
+ if (!viewerEmail)
142
+ return null;
143
+ const thread = await getThread(id).catch(() => null);
144
+ if (!thread)
145
+ return null;
146
+ if (thread.ownerEmail !== viewerEmail)
147
+ return null;
148
+ const title = thread.title.trim() || "Agent-Native thread";
149
+ return {
150
+ title,
151
+ description: previewDescription(thread),
152
+ imageUrl: extractThreadPreviewImageUrl(thread.threadData),
153
+ };
154
+ }
155
+ export function buildThreadLinkPreviewMeta(preview) {
156
+ const title = preview?.title ? `${preview.title} - Dispatch` : "Dispatch";
157
+ const description = preview?.description ||
158
+ "Open this Agent-Native thread in the Dispatch workspace.";
159
+ const image = preview?.imageUrl ?? null;
160
+ return [
161
+ { title },
162
+ { name: "description", content: description },
163
+ { property: "og:title", content: title },
164
+ { property: "og:description", content: description },
165
+ { property: "og:type", content: "website" },
166
+ ...(image ? [{ property: "og:image", content: image }] : []),
167
+ {
168
+ name: "twitter:card",
169
+ content: image ? "summary_large_image" : "summary",
170
+ },
171
+ { name: "twitter:title", content: title },
172
+ { name: "twitter:description", content: description },
173
+ ...(image ? [{ name: "twitter:image", content: image }] : []),
174
+ ];
175
+ }
176
+ //# sourceMappingURL=thread-link-preview.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"thread-link-preview.js","sourceRoot":"","sources":["../../../src/server/lib/thread-link-preview.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ChatThread } from \"@agent-native/core/server\";\nimport { getRequestContext, getThread } from \"@agent-native/core/server\";\n\nexport interface ThreadLinkPreview {\n title: string;\n description: string;\n imageUrl: string | null;\n}\n\nconst IMAGE_URL_KEYS = new Set([\n \"previewUrl\",\n \"thumbnailUrl\",\n \"imageUrl\",\n \"image\",\n \"downloadUrl\",\n]);\n\nconst GENERATION_TOOL_NAMES = new Set([\n \"generate-image\",\n \"generate-image-batch\",\n \"refine-image\",\n \"rerun-generation-run\",\n]);\n\nfunction safeJsonParse(value: string): unknown {\n try {\n return JSON.parse(value);\n } catch {\n return null;\n }\n}\n\nfunction cleanUrlCandidate(value: string): string {\n return value\n .trim()\n .replace(/[),.;\\]}]+$/g, \"\")\n .replace(/^[\"'(<]+/g, \"\");\n}\n\nfunction isAbsoluteHttpUrl(value: string): boolean {\n try {\n const url = new URL(value);\n return url.protocol === \"https:\" || url.protocol === \"http:\";\n } catch {\n return false;\n }\n}\n\nfunction isImageLikeUrl(value: string): boolean {\n try {\n const url = new URL(value);\n return (\n /\\.(?:png|jpe?g|webp|gif|avif)(?:$|[?#])/i.test(url.pathname) ||\n /\\/api\\/assets\\/[^/]+\\/content(?:$|[?#])/i.test(url.pathname)\n );\n } catch {\n return false;\n }\n}\n\nfunction validPreviewImageUrl(value: unknown, key?: string): string | null {\n if (typeof value !== \"string\") return null;\n const candidate = cleanUrlCandidate(value);\n if (!isAbsoluteHttpUrl(candidate)) return null;\n if (key && IMAGE_URL_KEYS.has(key)) return candidate;\n return isImageLikeUrl(candidate) ? candidate : null;\n}\n\nfunction imageUrlFromStructuredValue(value: unknown): string | null {\n if (!value || typeof value !== \"object\") return null;\n if (Array.isArray(value)) {\n for (let i = value.length - 1; i >= 0; i--) {\n const found = imageUrlFromStructuredValue(value[i]);\n if (found) return found;\n }\n return null;\n }\n\n const record = value as Record<string, unknown>;\n for (const key of IMAGE_URL_KEYS) {\n const found = validPreviewImageUrl(record[key], key);\n if (found) return found;\n }\n for (const [key, child] of Object.entries(record).reverse()) {\n const direct = validPreviewImageUrl(child, key);\n if (direct) return direct;\n if (child && typeof child === \"object\") {\n const nested = imageUrlFromStructuredValue(child);\n if (nested) return nested;\n }\n }\n return null;\n}\n\nfunction imageUrlFromText(value: string): string | null {\n const matches = value.match(/https?:\\/\\/[^\\s<>\"']+/g);\n if (!matches) return null;\n for (let i = matches.length - 1; i >= 0; i--) {\n const candidate = validPreviewImageUrl(matches[i]);\n if (candidate) return candidate;\n }\n return null;\n}\n\nexport function extractThreadPreviewImageUrl(\n threadData: string,\n): string | null {\n const parsed = safeJsonParse(threadData);\n if (!parsed || typeof parsed !== \"object\") return null;\n const messages = (parsed as { messages?: unknown }).messages;\n if (!Array.isArray(messages)) return null;\n\n for (\n let messageIndex = messages.length - 1;\n messageIndex >= 0;\n messageIndex--\n ) {\n const entry = messages[messageIndex] as any;\n const message = entry?.message ?? entry;\n const content = message?.content;\n if (!Array.isArray(content)) continue;\n\n for (let partIndex = content.length - 1; partIndex >= 0; partIndex--) {\n const part = content[partIndex] as Record<string, unknown>;\n const result = typeof part.result === \"string\" ? part.result : \"\";\n if (!result.trim()) continue;\n\n const toolName = typeof part.toolName === \"string\" ? part.toolName : \"\";\n const parsedResult = safeJsonParse(result);\n if (parsedResult && GENERATION_TOOL_NAMES.has(toolName)) {\n const structured = imageUrlFromStructuredValue(parsedResult);\n if (structured) return structured;\n }\n\n const fromText = imageUrlFromText(result);\n if (fromText) return fromText;\n }\n }\n return null;\n}\n\nfunction previewDescription(thread: ChatThread): string {\n const preview = thread.preview.trim();\n if (preview) return preview.slice(0, 180);\n return \"Open this Agent-Native thread in Dispatch.\";\n}\n\nexport async function loadThreadLinkPreview(\n threadId: string | null | undefined,\n): Promise<ThreadLinkPreview | null> {\n const id = threadId?.trim();\n if (!id) return null;\n const viewerEmail = getRequestContext()?.userEmail?.trim();\n if (!viewerEmail) return null;\n const thread = await getThread(id).catch(() => null);\n if (!thread) return null;\n if (thread.ownerEmail !== viewerEmail) return null;\n const title = thread.title.trim() || \"Agent-Native thread\";\n return {\n title,\n description: previewDescription(thread),\n imageUrl: extractThreadPreviewImageUrl(thread.threadData),\n };\n}\n\nexport function buildThreadLinkPreviewMeta(preview: ThreadLinkPreview | null) {\n const title = preview?.title ? `${preview.title} - Dispatch` : \"Dispatch\";\n const description =\n preview?.description ||\n \"Open this Agent-Native thread in the Dispatch workspace.\";\n const image = preview?.imageUrl ?? null;\n return [\n { title },\n { name: \"description\", content: description },\n { property: \"og:title\", content: title },\n { property: \"og:description\", content: description },\n { property: \"og:type\", content: \"website\" },\n ...(image ? [{ property: \"og:image\", content: image }] : []),\n {\n name: \"twitter:card\",\n content: image ? \"summary_large_image\" : \"summary\",\n },\n { name: \"twitter:title\", content: title },\n { name: \"twitter:description\", content: description },\n ...(image ? [{ name: \"twitter:image\", content: image }] : []),\n ];\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/dispatch",
3
- "version": "0.8.20",
3
+ "version": "0.8.23",
4
4
  "type": "module",
5
5
  "description": "Dispatch — workspace control plane for agent-native apps. Vault, integrations, destinations, scheduled jobs, and cross-app delegation, shipped as a single drop-in package.",
6
6
  "license": "MIT",
@@ -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."
@@ -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
+ });