@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.
- package/dist/routes/pages/chat.d.ts +21 -2
- package/dist/routes/pages/chat.d.ts.map +1 -1
- package/dist/routes/pages/chat.js +12 -3
- package/dist/routes/pages/chat.js.map +1 -1
- package/dist/routes/pages/overview.d.ts +21 -2
- package/dist/routes/pages/overview.d.ts.map +1 -1
- package/dist/routes/pages/overview.js +13 -4
- package/dist/routes/pages/overview.js.map +1 -1
- package/dist/server/lib/dispatch-integrations.d.ts.map +1 -1
- package/dist/server/lib/dispatch-integrations.js +27 -3
- package/dist/server/lib/dispatch-integrations.js.map +1 -1
- package/dist/server/lib/thread-link-preview.d.ts +24 -0
- package/dist/server/lib/thread-link-preview.d.ts.map +1 -0
- package/dist/server/lib/thread-link-preview.js +176 -0
- package/dist/server/lib/thread-link-preview.js.map +1 -0
- package/package.json +1 -1
- package/src/routes/pages/chat.tsx +20 -3
- package/src/routes/pages/overview.tsx +21 -8
- package/src/server/lib/dispatch-integrations.spec.ts +69 -0
- package/src/server/lib/dispatch-integrations.ts +26 -3
- package/src/server/lib/thread-link-preview.spec.ts +129 -0
- package/src/server/lib/thread-link-preview.ts +187 -0
|
@@ -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":"AACA,OAAO,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAQzE,MAAM,cAAc,GAAG,IAAI,GAAG,CAAC;IAC7B,YAAY;IACZ,cAAc;IACd,UAAU;IACV,OAAO;IACP,aAAa;CACd,CAAC,CAAC;AAEH,MAAM,qBAAqB,GAAG,IAAI,GAAG,CAAC;IACpC,gBAAgB;IAChB,sBAAsB;IACtB,cAAc;IACd,sBAAsB;CACvB,CAAC,CAAC;AAEH,SAAS,aAAa,CAAC,KAAa;IAClC,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IAC3B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,SAAS,iBAAiB,CAAC,KAAa;IACtC,OAAO,KAAK;SACT,IAAI,EAAE;SACN,OAAO,CAAC,cAAc,EAAE,EAAE,CAAC;SAC3B,OAAO,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC;AAC9B,CAAC;AAED,SAAS,iBAAiB,CAAC,KAAa;IACtC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC;QAC3B,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO,CAAC;IAC/D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,SAAS,cAAc,CAAC,KAAa;IACnC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC;QAC3B,OAAO,CACL,0CAA0C,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC;YAC7D,0CAA0C,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,CAC9D,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,SAAS,oBAAoB,CAAC,KAAc,EAAE,GAAY;IACxD,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC3C,MAAM,SAAS,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAC;IAC3C,IAAI,CAAC,iBAAiB,CAAC,SAAS,CAAC;QAAE,OAAO,IAAI,CAAC;IAC/C,IAAI,GAAG,IAAI,cAAc,CAAC,GAAG,CAAC,GAAG,CAAC;QAAE,OAAO,SAAS,CAAC;IACrD,OAAO,cAAc,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC;AACtD,CAAC;AAED,SAAS,2BAA2B,CAAC,KAAc;IACjD,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IACrD,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACzB,KAAK,IAAI,CAAC,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;YAC3C,MAAM,KAAK,GAAG,2BAA2B,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;YACpD,IAAI,KAAK;gBAAE,OAAO,KAAK,CAAC;QAC1B,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,MAAM,GAAG,KAAgC,CAAC;IAChD,KAAK,MAAM,GAAG,IAAI,cAAc,EAAE,CAAC;QACjC,MAAM,KAAK,GAAG,oBAAoB,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;QACrD,IAAI,KAAK;YAAE,OAAO,KAAK,CAAC;IAC1B,CAAC;IACD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC;QAC5D,MAAM,MAAM,GAAG,oBAAoB,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QAChD,IAAI,MAAM;YAAE,OAAO,MAAM,CAAC;QAC1B,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YACvC,MAAM,MAAM,GAAG,2BAA2B,CAAC,KAAK,CAAC,CAAC;YAClD,IAAI,MAAM;gBAAE,OAAO,MAAM,CAAC;QAC5B,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,gBAAgB,CAAC,KAAa;IACrC,MAAM,OAAO,GAAG,KAAK,CAAC,KAAK,CAAC,wBAAwB,CAAC,CAAC;IACtD,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAC;IAC1B,KAAK,IAAI,CAAC,GAAG,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QAC7C,MAAM,SAAS,GAAG,oBAAoB,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;QACnD,IAAI,SAAS;YAAE,OAAO,SAAS,CAAC;IAClC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,4BAA4B,CAC1C,UAAkB;IAElB,MAAM,MAAM,GAAG,aAAa,CAAC,UAAU,CAAC,CAAC;IACzC,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IACvD,MAAM,QAAQ,GAAI,MAAiC,CAAC,QAAQ,CAAC;IAC7D,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC;QAAE,OAAO,IAAI,CAAC;IAE1C,KACE,IAAI,YAAY,GAAG,QAAQ,CAAC,MAAM,GAAG,CAAC,EACtC,YAAY,IAAI,CAAC,EACjB,YAAY,EAAE,EACd,CAAC;QACD,MAAM,KAAK,GAAG,QAAQ,CAAC,YAAY,CAAQ,CAAC;QAC5C,MAAM,OAAO,GAAG,KAAK,EAAE,OAAO,IAAI,KAAK,CAAC;QACxC,MAAM,OAAO,GAAG,OAAO,EAAE,OAAO,CAAC;QACjC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;YAAE,SAAS;QAEtC,KAAK,IAAI,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,SAAS,IAAI,CAAC,EAAE,SAAS,EAAE,EAAE,CAAC;YACrE,MAAM,IAAI,GAAG,OAAO,CAAC,SAAS,CAA4B,CAAC;YAC3D,MAAM,MAAM,GAAG,OAAO,IAAI,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC;YAClE,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE;gBAAE,SAAS;YAE7B,MAAM,QAAQ,GAAG,OAAO,IAAI,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;YACxE,MAAM,YAAY,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC;YAC3C,IAAI,YAAY,IAAI,qBAAqB,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC;gBACxD,MAAM,UAAU,GAAG,2BAA2B,CAAC,YAAY,CAAC,CAAC;gBAC7D,IAAI,UAAU;oBAAE,OAAO,UAAU,CAAC;YACpC,CAAC;YAED,MAAM,QAAQ,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAAC;YAC1C,IAAI,QAAQ;gBAAE,OAAO,QAAQ,CAAC;QAChC,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,kBAAkB,CAAC,MAAkB;IAC5C,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;IACtC,IAAI,OAAO;QAAE,OAAO,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IAC1C,OAAO,4CAA4C,CAAC;AACtD,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,QAAmC;IAEnC,MAAM,EAAE,GAAG,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC5B,IAAI,CAAC,EAAE;QAAE,OAAO,IAAI,CAAC;IACrB,MAAM,WAAW,GAAG,iBAAiB,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;IAC3D,IAAI,CAAC,WAAW;QAAE,OAAO,IAAI,CAAC;IAC9B,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;IACrD,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IACzB,IAAI,MAAM,CAAC,UAAU,KAAK,WAAW;QAAE,OAAO,IAAI,CAAC;IACnD,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,qBAAqB,CAAC;IAC3D,OAAO;QACL,KAAK;QACL,WAAW,EAAE,kBAAkB,CAAC,MAAM,CAAC;QACvC,QAAQ,EAAE,4BAA4B,CAAC,MAAM,CAAC,UAAU,CAAC;KAC1D,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,0BAA0B,CAAC,OAAiC;IAC1E,MAAM,KAAK,GAAG,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,KAAK,aAAa,CAAC,CAAC,CAAC,UAAU,CAAC;IAC1E,MAAM,WAAW,GACf,OAAO,EAAE,WAAW;QACpB,0DAA0D,CAAC;IAC7D,MAAM,KAAK,GAAG,OAAO,EAAE,QAAQ,IAAI,IAAI,CAAC;IACxC,OAAO;QACL,EAAE,KAAK,EAAE;QACT,EAAE,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,WAAW,EAAE;QAC7C,EAAE,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,KAAK,EAAE;QACxC,EAAE,QAAQ,EAAE,gBAAgB,EAAE,OAAO,EAAE,WAAW,EAAE;QACpD,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE;QAC3C,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC5D;YACE,IAAI,EAAE,cAAc;YACpB,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,SAAS;SACnD;QACD,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,KAAK,EAAE;QACzC,EAAE,IAAI,EAAE,qBAAqB,EAAE,OAAO,EAAE,WAAW,EAAE;QACrD,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;KAC9D,CAAC;AACJ,CAAC","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.
|
|
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 {
|
|
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
|
|
19
|
-
|
|
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
|
|
475
|
-
|
|
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
|
|
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
|
|
168
|
-
//
|
|
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
|
-
|
|
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
|
+
});
|