@marktiderman/genesis-chat 0.1.1 → 0.2.1

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 (175) hide show
  1. package/README.md +379 -13
  2. package/dist/chunk-A4YJX7KT.js +544 -0
  3. package/dist/chunk-A4YJX7KT.js.map +1 -0
  4. package/dist/chunk-AZS3HNLM.js +30 -0
  5. package/dist/chunk-AZS3HNLM.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-GBJ3YI4E.js +966 -0
  9. package/dist/chunk-GBJ3YI4E.js.map +1 -0
  10. package/dist/chunk-JU2FAPBI.js +609 -0
  11. package/dist/chunk-JU2FAPBI.js.map +1 -0
  12. package/dist/chunk-VEPZROQB.js +187 -0
  13. package/dist/chunk-VEPZROQB.js.map +1 -0
  14. package/dist/{client-BtdALFCx.d.ts → client-DUIG1hum.d.ts} +187 -11
  15. package/dist/index.d.ts +78 -163
  16. package/dist/index.js +3 -2
  17. package/dist/native.d.ts +1939 -1
  18. package/dist/native.js +5023 -0
  19. package/dist/native.js.map +1 -1
  20. package/dist/outbox-B9NAWQ58.d.ts +219 -0
  21. package/dist/push.d.ts +206 -0
  22. package/dist/push.js +3 -0
  23. package/dist/push.js.map +1 -0
  24. package/dist/react.d.ts +176 -7
  25. package/dist/react.js +3 -3
  26. package/dist/server.d.ts +551 -21
  27. package/dist/server.js +976 -34
  28. package/dist/server.js.map +1 -1
  29. package/dist/thread-oPra7pkW.d.ts +57 -0
  30. package/dist/web.d.ts +335 -12
  31. package/dist/web.js +1784 -64
  32. package/dist/web.js.map +1 -1
  33. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  34. package/migrations/0003_genesis_chat_search_threads.sql +85 -0
  35. package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
  36. package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
  37. package/package.json +78 -6
  38. package/src/__tests__/coaching-core.test.ts +334 -0
  39. package/src/__tests__/coaching-react.test.tsx +319 -0
  40. package/src/__tests__/coaching-web.test.tsx +521 -0
  41. package/src/__tests__/core.test.ts +91 -0
  42. package/src/__tests__/fake-xhr.ts +54 -0
  43. package/src/__tests__/outbox-hosted.test.ts +269 -0
  44. package/src/__tests__/outbox.test.ts +122 -0
  45. package/src/__tests__/realtime.test.ts +70 -0
  46. package/src/__tests__/review-279.test.ts +41 -0
  47. package/src/__tests__/review-fixes-core.test.ts +193 -0
  48. package/src/__tests__/review-fixes-web.test.tsx +405 -0
  49. package/src/__tests__/server-effects.test.ts +158 -0
  50. package/src/__tests__/server-fake-video.test.ts +81 -0
  51. package/src/__tests__/server-fakes.ts +96 -0
  52. package/src/__tests__/server-integrations.test.ts +247 -0
  53. package/src/__tests__/server-providers.test.ts +246 -0
  54. package/src/__tests__/server-runner.test.ts +277 -0
  55. package/src/__tests__/server-safety.test.ts +303 -0
  56. package/src/__tests__/web-markdown.test.tsx +88 -0
  57. package/src/__tests__/web.test.tsx +349 -0
  58. package/src/core/availability.ts +223 -0
  59. package/src/core/client.ts +783 -0
  60. package/src/core/extras.ts +106 -0
  61. package/src/core/media.ts +144 -0
  62. package/src/core/outbox.ts +635 -0
  63. package/src/core/thread.ts +171 -0
  64. package/src/core/transfer.ts +71 -0
  65. package/src/core/types.ts +328 -0
  66. package/src/index.ts +14 -0
  67. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  68. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  69. package/src/native/__tests__/composer-state.test.ts +188 -0
  70. package/src/native/__tests__/composer.test.tsx +133 -0
  71. package/src/native/__tests__/emoji.test.tsx +254 -0
  72. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  73. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  74. package/src/native/__tests__/organise-logic.test.ts +164 -0
  75. package/src/native/__tests__/organise-screens.test.tsx +424 -0
  76. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  77. package/src/native/__tests__/push-payload.test.ts +66 -0
  78. package/src/native/__tests__/push.test.ts +209 -0
  79. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  80. package/src/native/__tests__/rows-actions.test.ts +76 -0
  81. package/src/native/__tests__/thread-layout.test.ts +41 -0
  82. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  83. package/src/native/__tests__/tus.test.ts +154 -0
  84. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  85. package/src/native/__tests__/video-media.test.tsx +498 -0
  86. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  87. package/src/native/__tests__/video-upload.test.ts +227 -0
  88. package/src/native/access-error.tsx +31 -0
  89. package/src/native/actions.ts +42 -0
  90. package/src/native/attachment-cards.tsx +132 -0
  91. package/src/native/auto-retry.ts +69 -0
  92. package/src/native/availability.tsx +376 -0
  93. package/src/native/broadcast-sheet.tsx +193 -0
  94. package/src/native/composer-state.ts +302 -0
  95. package/src/native/composer.tsx +453 -0
  96. package/src/native/context.tsx +70 -0
  97. package/src/native/copy.ts +296 -0
  98. package/src/native/emoji-data.ts +21 -0
  99. package/src/native/emoji-picker.tsx +229 -0
  100. package/src/native/emoji.ts +118 -0
  101. package/src/native/icon-button.tsx +40 -0
  102. package/src/native/inbox-screen.tsx +122 -0
  103. package/src/native/index.ts +184 -0
  104. package/src/native/link-preview.tsx +104 -0
  105. package/src/native/media-colors.ts +15 -0
  106. package/src/native/media.tsx +187 -0
  107. package/src/native/message-actions-sheet.tsx +126 -0
  108. package/src/native/message-bubble.tsx +366 -0
  109. package/src/native/moderation.tsx +42 -0
  110. package/src/native/modules.ts +277 -0
  111. package/src/native/organise-copy.ts +195 -0
  112. package/src/native/photo-viewer.tsx +531 -0
  113. package/src/native/push.ts +375 -0
  114. package/src/native/recorder-machine.ts +176 -0
  115. package/src/native/rows.ts +81 -0
  116. package/src/native/schedule-sheet.tsx +328 -0
  117. package/src/native/search-screen.tsx +189 -0
  118. package/src/native/stack-options.ts +36 -0
  119. package/src/native/start-sheet.tsx +87 -0
  120. package/src/native/thread-screen.tsx +647 -0
  121. package/src/native/topics.tsx +234 -0
  122. package/src/native/tus.ts +282 -0
  123. package/src/native/use-composer.ts +71 -0
  124. package/src/native/use-jump.ts +91 -0
  125. package/src/native/use-voice-recorder.ts +234 -0
  126. package/src/native/video-player.tsx +410 -0
  127. package/src/native/video-recorder-state.ts +140 -0
  128. package/src/native/video-recorder.tsx +274 -0
  129. package/src/native/video-upload-tray.tsx +77 -0
  130. package/src/native/video-upload.ts +432 -0
  131. package/src/native/voice-player.tsx +301 -0
  132. package/src/react/index.tsx +894 -0
  133. package/src/react/previews.ts +51 -0
  134. package/src/server/assistant.ts +235 -0
  135. package/src/server/crypto.ts +86 -0
  136. package/src/server/expo.ts +119 -0
  137. package/src/server/fake-video.ts +171 -0
  138. package/src/server/handler.ts +32 -0
  139. package/src/server/index.ts +58 -0
  140. package/src/server/moderation.ts +155 -0
  141. package/src/server/mux.ts +290 -0
  142. package/src/server/notifications.ts +130 -0
  143. package/src/server/runner.ts +468 -0
  144. package/src/server/transcribers.ts +111 -0
  145. package/src/server/types.ts +171 -0
  146. package/src/server/unfurl.ts +480 -0
  147. package/src/server/upload.ts +158 -0
  148. package/src/web/attachments.tsx +379 -0
  149. package/src/web/availability.tsx +299 -0
  150. package/src/web/broadcast.tsx +166 -0
  151. package/src/web/bubble.tsx +585 -0
  152. package/src/web/cards.tsx +136 -0
  153. package/src/web/composer.tsx +571 -0
  154. package/src/web/emoji-picker.tsx +84 -0
  155. package/src/web/error-boundary.tsx +33 -0
  156. package/src/web/format.ts +55 -0
  157. package/src/web/inbox.tsx +196 -0
  158. package/src/web/index.ts +75 -0
  159. package/src/web/markdown.tsx +245 -0
  160. package/src/web/memory-client.ts +714 -0
  161. package/src/web/panel.tsx +135 -0
  162. package/src/web/schedule.tsx +204 -0
  163. package/src/web/search.tsx +129 -0
  164. package/src/web/thread-context.ts +64 -0
  165. package/src/web/thread.tsx +830 -0
  166. package/src/web/topics.tsx +160 -0
  167. package/src/web/use-voice-recorder.ts +264 -0
  168. package/src/web/video.tsx +516 -0
  169. package/src/web/voice-note.tsx +197 -0
  170. package/dist/chunk-27UTR5FW.js +0 -322
  171. package/dist/chunk-27UTR5FW.js.map +0 -1
  172. package/dist/chunk-JQXZUEEL.js +0 -525
  173. package/dist/chunk-JQXZUEEL.js.map +0 -1
  174. package/dist/chunk-SAY24E6V.js +0 -309
  175. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -0,0 +1,303 @@
1
+ /** @feat:chat Moderation mapping and the SSRF-safe link unfurler. */
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { carriesCredential, createLinkUnfurler, guardedLookup, isPrivateAddress, moderationVerdict, openAiModerator, parseOpenGraph } from "../server";
4
+ import { fakeFetch, jsonResponse } from "./server-fakes";
5
+
6
+ describe("moderationVerdict", () => {
7
+ it("maps scores to clean, flagged and hidden with default thresholds", () => {
8
+ expect(moderationVerdict({ harassment: 0.1, violence: 0.2 })).toEqual({ result: "clean" });
9
+ expect(moderationVerdict({ harassment: 0.6 })).toEqual({ result: "flagged", reason: "Automatic moderation: harassment 0.60" });
10
+ expect(moderationVerdict({ harassment: 0.95, violence: 0.55 })).toEqual({
11
+ result: "hidden",
12
+ reason: "Automatic moderation: harassment 0.95, violence 0.55",
13
+ });
14
+ });
15
+
16
+ it("is stricter for sexual/minors and softer to flag self-harm by default", () => {
17
+ expect(moderationVerdict({ "sexual/minors": 0.55 }).result).toBe("hidden");
18
+ expect(moderationVerdict({ "self-harm/intent": 0.35 }).result).toBe("flagged");
19
+ expect(moderationVerdict({ "self-harm/intent": 0.92 }).result).toBe("flagged");
20
+ });
21
+
22
+ it("takes configured thresholds, and the provider's flag", () => {
23
+ expect(moderationVerdict({ harassment: 0.6 }, { thresholds: { flag: 0.7 } }).result).toBe("clean");
24
+ expect(moderationVerdict({ violence: 0.6 }, { categories: { violence: { hide: 0.5 } } }).result).toBe("hidden");
25
+ expect(moderationVerdict({ harassment: 0.1 }, { flagged: true }).result).toBe("flagged");
26
+ });
27
+ });
28
+
29
+ describe("openAiModerator", () => {
30
+ it("sends text with the first photo, further photos alone, and keeps the highest scores", async () => {
31
+ const f = fakeFetch((_url, init) => {
32
+ const body = JSON.parse(String(init!.body)) as { input: { type: string }[] };
33
+ const withText = body.input.some((i) => i.type === "text");
34
+ return jsonResponse({ results: [{ flagged: false, category_scores: withText ? { harassment: 0.2, violence: 0.1 } : { violence: 0.93 } }] });
35
+ });
36
+ const verdict = await openAiModerator({ apiKey: "sk-test", fetch: f.fetch }).moderate({ text: "hi", imageUrls: ["https://f.test/1", "https://f.test/2"] });
37
+ expect(verdict).toEqual({ result: "hidden", reason: "Automatic moderation: violence 0.93" });
38
+ expect(f.calls).toHaveLength(2);
39
+ expect(f.calls[0]!.url).toBe("https://api.openai.com/v1/moderations");
40
+ expect((f.calls[0]!.init!.headers as Record<string, string>).authorization).toBe("Bearer sk-test");
41
+ expect(JSON.parse(String(f.calls[0]!.init!.body))).toEqual({
42
+ model: "omni-moderation-latest",
43
+ input: [
44
+ { type: "text", text: "hi" },
45
+ { type: "image_url", image_url: { url: "https://f.test/1" } },
46
+ ],
47
+ });
48
+ });
49
+
50
+ it("throws on an HTTP error so the effect retries", async () => {
51
+ const m = openAiModerator({ apiKey: "k", fetch: fakeFetch(() => new Response("no", { status: 429 })).fetch });
52
+ await expect(m.moderate({ text: "x", imageUrls: [] })).rejects.toThrow("HTTP 429");
53
+ });
54
+ });
55
+
56
+ describe("isPrivateAddress", () => {
57
+ it.each([
58
+ "127.0.0.1",
59
+ "10.1.2.3",
60
+ "172.16.0.1",
61
+ "192.168.1.1",
62
+ "169.254.169.254",
63
+ "100.64.0.1",
64
+ "0.0.0.0",
65
+ "224.0.0.1",
66
+ "::1",
67
+ "::",
68
+ "[::1]",
69
+ "fe80::1",
70
+ "fd00:ec2::254",
71
+ "::ffff:127.0.0.1",
72
+ "::ffff:7f00:1",
73
+ "64:ff9b::a9fe:a9fe",
74
+ "::ffff:0:7f00:1", // IPv4-translated 127.0.0.1
75
+ "::ffff:0:a00:1", // IPv4-translated 10.0.0.1
76
+ "2002:7f00:1::", // 6to4 of 127.0.0.1
77
+ "2002:a9fe:a9fe::1", // 6to4 of 169.254.169.254
78
+ "2001::1", // Teredo
79
+ "64:ff9b:1::1", // local-use NAT64
80
+ "3fff::1", // documentation
81
+ "192.88.99.1", // 6to4 relay
82
+ "not-an-ip",
83
+ ])("blocks %s", (ip) => expect(isPrivateAddress(ip)).toBe(true));
84
+
85
+ it.each(["93.184.216.34", "1.1.1.1", "2606:4700:4700::1111", "::ffff:93.184.216.34", "2002:5db8:d822::1", "2001:4860:4860::8888"])("allows %s", (ip) => expect(isPrivateAddress(ip)).toBe(false));
86
+ });
87
+
88
+ const html = (head: string, extra = "") => `<!doctype html><html><head>${head}</head><body>${extra}</body></html>`;
89
+ const page = (body: string, headers: Record<string, string> = {}) =>
90
+ new Response(body, { status: 200, headers: { "content-type": "text/html; charset=utf-8", ...headers } });
91
+ const publicDns = async () => ["93.184.216.34"];
92
+
93
+ describe("createLinkUnfurler", () => {
94
+ it("parses Open Graph data with entities, caps and an https image", async () => {
95
+ const f = fakeFetch(() =>
96
+ page(
97
+ html(
98
+ `<title>Fallback</title><meta property="og:title" content="Tom &amp; Jerry&#39;s &#x1F600; plan">` +
99
+ `<meta content='Week 3\n of 12' property='og:description'><meta property="og:image" content="/img/cover.png">` +
100
+ `<meta property="og:site_name" content="Coach&nbsp;Co">`,
101
+ ),
102
+ ),
103
+ );
104
+ const preview = await createLinkUnfurler({ fetch: f.fetch, resolve: publicDns }).unfurl("https://coach.example/plan");
105
+ expect(preview).toEqual({
106
+ url: "https://coach.example/plan",
107
+ status: "ok",
108
+ title: "Tom & Jerry's \u{1F600} plan",
109
+ description: "Week 3 of 12",
110
+ imageUrl: "https://coach.example/img/cover.png",
111
+ siteName: "Coach Co",
112
+ });
113
+ expect(f.calls[0]!.init!.redirect).toBe("manual");
114
+ });
115
+
116
+ it("falls back to <title> and meta description, caps lengths and drops a non-https image", () => {
117
+ const og = parseOpenGraph(
118
+ html(`<title> A page </title><meta name="description" content="${"d".repeat(700)}"><meta property="og:image" content="http://x.example/i.png">`),
119
+ "https://x.example/",
120
+ );
121
+ expect(og.title).toBe("A page");
122
+ expect(og.description).toHaveLength(600);
123
+ expect(og.imageUrl).toBeNull();
124
+ });
125
+
126
+ it("ignores tags outside the head and inside scripts or comments", () => {
127
+ const og = parseOpenGraph(
128
+ `<head><!-- <meta property="og:title" content="comment"> --><script>var s='<meta property="og:title" content="script">'</script>` +
129
+ `<meta property="og:title" content="real"></head><body><meta property="og:description" content="body"></body>`,
130
+ "https://x.example/",
131
+ );
132
+ expect(og).toMatchObject({ title: "real", description: null });
133
+ });
134
+
135
+ // Each of these took seconds when every unclosed start rescanned to the end of the page.
136
+ it.each([
137
+ ["unclosed scripts", "<script "],
138
+ ["unclosed comments", "<!--"],
139
+ ["unclosed metas", "<meta "],
140
+ ["unclosed titles", "<title>"],
141
+ ["an attribute's open quotes", "<meta x='"],
142
+ ["a long attribute name", "a"],
143
+ ["unclosed tags in a title", "<title>" + "<".repeat(1000)],
144
+ ])("parses a 512 KiB page of %s in linear time", (_, unit) => {
145
+ // Time grows with the page, not its square: four times the bytes may take about four times as
146
+ // long (quadratic would take sixteen). Compared against the same machine's smaller page, so a
147
+ // slow CI runner can't fail it; the best of three runs keeps a pause from counting.
148
+ const timeFor = (bytes: number) => {
149
+ const page = unit.repeat(Math.ceil(bytes / unit.length));
150
+ const html = unit === "a" ? `<meta ${page}>` : page;
151
+ let best = Infinity;
152
+ for (let i = 0; i < 3; i++) {
153
+ const started = performance.now();
154
+ parseOpenGraph(html, "https://x.example/");
155
+ best = Math.min(best, performance.now() - started);
156
+ }
157
+ return best;
158
+ };
159
+ const small = timeFor(128 * 1024);
160
+ const large = timeFor(512 * 1024);
161
+ expect(large).toBeLessThan(small * 10 + 50);
162
+ });
163
+
164
+ it("still reads a title and an unclosed script runs to the end", () => {
165
+ expect(parseOpenGraph(`<title>Hi</title><script>var x = 1;<meta property="og:title" content="no">`, "https://x.example/").title).toBe("Hi");
166
+ });
167
+
168
+ it.each([
169
+ ["http://coach.example/", "blocked"],
170
+ ["https://127.0.0.1/", "blocked"],
171
+ ["https://[::1]/", "blocked"],
172
+ ["https://169.254.169.254/latest/meta-data", "blocked"],
173
+ ["https://2130706433/", "blocked"],
174
+ ["https://localhost/", "blocked"],
175
+ ["https://metadata.google.internal/", "blocked"],
176
+ ["https://intranet/", "blocked"],
177
+ ["https://user:pw@coach.example/", "blocked"],
178
+ ["https://coach.example:8443/", "blocked"],
179
+ ])("refuses %s without fetching", async (url, status) => {
180
+ const f = fakeFetch(() => page(html("<title>x</title>")));
181
+ expect((await createLinkUnfurler({ fetch: f.fetch, resolve: publicDns }).unfurl(url)).status).toBe(status);
182
+ expect(f.calls).toHaveLength(0);
183
+ });
184
+
185
+ it("refuses a name that resolves to a private address", async () => {
186
+ const f = fakeFetch(() => page(html("<title>x</title>")));
187
+ const u = createLinkUnfurler({ fetch: f.fetch, resolve: async () => ["93.184.216.34", "10.0.0.5"] });
188
+ expect((await u.unfurl("https://sneaky.example/")).status).toBe("blocked");
189
+ expect(f.calls).toHaveLength(0);
190
+ });
191
+
192
+ it("checks every redirect hop, and refuses a redirect into a private address", async () => {
193
+ const f = fakeFetch((url) =>
194
+ url === "https://coach.example/a"
195
+ ? new Response(null, { status: 302, headers: { location: "https://internal.example/admin" } })
196
+ : page(html("<title>secret</title>")),
197
+ );
198
+ const resolve = async (host: string) => (host === "internal.example" ? ["192.168.0.10"] : ["93.184.216.34"]);
199
+ expect((await createLinkUnfurler({ fetch: f.fetch, resolve }).unfurl("https://coach.example/a")).status).toBe("blocked");
200
+ expect(f.calls.map((c) => c.url)).toEqual(["https://coach.example/a"]);
201
+
202
+ const literal = fakeFetch(() => new Response(null, { status: 301, headers: { location: "https://169.254.169.254/" } }));
203
+ expect((await createLinkUnfurler({ fetch: literal.fetch, resolve: publicDns }).unfurl("https://coach.example/")).status).toBe("blocked");
204
+ const downgrade = fakeFetch(() => new Response(null, { status: 301, headers: { location: "http://coach.example/" } }));
205
+ expect((await createLinkUnfurler({ fetch: downgrade.fetch, resolve: publicDns }).unfurl("https://coach.example/")).status).toBe("blocked");
206
+ });
207
+
208
+ it("follows at most 3 redirects", async () => {
209
+ let n = 0;
210
+ const f = fakeFetch(() => new Response(null, { status: 302, headers: { location: `/hop${++n}` } }));
211
+ expect((await createLinkUnfurler({ fetch: f.fetch, resolve: publicDns }).unfurl("https://coach.example/")).status).toBe("failed");
212
+ expect(f.calls).toHaveLength(4);
213
+
214
+ let m = 0;
215
+ const ok = fakeFetch(() => (++m <= 3 ? new Response(null, { status: 302, headers: { location: `/hop${m}` } }) : page(html("<title>Here</title>"))));
216
+ expect(await createLinkUnfurler({ fetch: ok.fetch, resolve: publicDns }).unfurl("https://coach.example/")).toMatchObject({ status: "ok", title: "Here" });
217
+ });
218
+
219
+ it("fails on non-HTML, a declared oversize body, an HTTP error or a network error", async () => {
220
+ const run = (respond: () => Response | Promise<Response>, maxBytes?: number) =>
221
+ createLinkUnfurler({ fetch: fakeFetch(respond).fetch, resolve: publicDns, ...(maxBytes ? { maxBytes } : {}) }).unfurl("https://coach.example/");
222
+ expect((await run(() => new Response("%PDF", { headers: { "content-type": "application/pdf" } }))).status).toBe("failed");
223
+ expect((await run(() => new Response("{}", { headers: { "content-type": "application/json" } }))).status).toBe("failed");
224
+ expect((await run(() => page(html("<title>x</title>"), { "content-length": "999999" }), 1000)).status).toBe("failed");
225
+ expect((await run(() => new Response("gone", { status: 404, headers: { "content-type": "text/html" } }))).status).toBe("failed");
226
+ expect((await run(() => Promise.reject(new TypeError("fetch failed")))).status).toBe("failed");
227
+ });
228
+
229
+ it("reads no more than maxBytes of an undeclared body", async () => {
230
+ let pulled = 0;
231
+ const stream = new ReadableStream<Uint8Array>({
232
+ pull(controller) {
233
+ pulled++;
234
+ controller.enqueue(new TextEncoder().encode(pulled === 1 ? '<head><meta property="og:title" content="Top"></head>' : "x".repeat(1000)));
235
+ if (pulled > 1000) controller.close();
236
+ },
237
+ });
238
+ const f = fakeFetch(() => new Response(stream, { headers: { "content-type": "text/html" } }));
239
+ expect(await createLinkUnfurler({ fetch: f.fetch, resolve: publicDns, maxBytes: 4096 }).unfurl("https://coach.example/")).toMatchObject({ status: "ok", title: "Top" });
240
+ expect(pulled).toBeLessThan(10);
241
+ });
242
+
243
+ it("without a resolver, refuses every named host (logged once) unless allowUnresolved", async () => {
244
+ const f = fakeFetch(() => page(html("<title>x</title>")));
245
+ const log = vi.fn();
246
+ const u = createLinkUnfurler({ fetch: f.fetch, resolve: async () => null, log });
247
+ expect((await u.unfurl("https://10.0.0.1/")).status).toBe("blocked");
248
+ expect((await u.unfurl("https://localtest.me/")).status).toBe("blocked");
249
+ expect((await u.unfurl("https://coach.example/")).status).toBe("blocked");
250
+ expect(f.calls).toHaveLength(0);
251
+ expect(log).toHaveBeenCalledTimes(1);
252
+ expect(log.mock.calls[0]![0]).toContain("allowUnresolved");
253
+
254
+ const opted = createLinkUnfurler({ fetch: f.fetch, resolve: async () => null, allowUnresolved: true, log });
255
+ expect((await opted.unfurl("https://coach.example/")).status).toBe("ok");
256
+ expect((await opted.unfurl("https://10.0.0.1/")).status).toBe("blocked");
257
+ });
258
+
259
+ it("times out a DNS lookup that hangs", async () => {
260
+ const f = fakeFetch(() => page(html("<title>x</title>")));
261
+ const u = createLinkUnfurler({ fetch: f.fetch, resolve: () => new Promise<string[]>(() => {}), timeoutMs: 50 });
262
+ expect((await u.unfurl("https://slow.example/")).status).toBe("failed");
263
+ expect(f.calls).toHaveLength(0);
264
+ });
265
+
266
+ it("never fetches a link whose query carries a credential", async () => {
267
+ const f = fakeFetch(() => page(html("<title>x</title>")));
268
+ const u = createLinkUnfurler({ fetch: f.fetch, resolve: publicDns });
269
+ for (const url of ["https://app.example/reset?token=abc", "https://s3.example/f?X-Amz-Signature=1&a=b", "https://app.example/login?a=1&code=xyz"]) {
270
+ expect((await u.unfurl(url)).status).toBe("blocked");
271
+ }
272
+ expect(f.calls).toHaveLength(0);
273
+ expect(carriesCredential("https://app.example/page?ref=newsletter")).toBe(false);
274
+ });
275
+
276
+ it("drops an og:image whose host is private or local", async () => {
277
+ const run = async (image: string, resolve: (host: string) => Promise<string[]> = publicDns) =>
278
+ (await createLinkUnfurler({ fetch: fakeFetch(() => page(html(`<title>T</title><meta property="og:image" content="${image}">`))).fetch, resolve }).unfurl(
279
+ "https://coach.example/",
280
+ )).imageUrl;
281
+ expect(await run("https://10.0.0.1/i.png")).toBeNull();
282
+ expect(await run("https://intranet.local/i.png")).toBeNull();
283
+ expect(await run("https://cdn.example/i.png", async (h) => (h === "cdn.example" ? ["192.168.1.9"] : ["93.184.216.34"]))).toBeNull();
284
+ expect(await run("https://cdn.example/i.png?sig=abc")).toBe("https://cdn.example/i.png?sig=abc");
285
+ });
286
+
287
+ it("passes a dispatcher to fetch, and guardedLookup refuses a private answer at connect time", async () => {
288
+ const f = fakeFetch(() => page(html("<title>x</title>")));
289
+ const dispatcher = { pinned: true };
290
+ await createLinkUnfurler({ fetch: f.fetch, resolve: publicDns, dispatcher }).unfurl("https://coach.example/");
291
+ expect((f.calls[0]!.init as RequestInit & { dispatcher?: unknown }).dispatcher).toBe(dispatcher);
292
+
293
+ const lookup = (host: string, all: boolean) =>
294
+ new Promise<{ error: Error | null; address?: unknown }>((resolve) =>
295
+ guardedLookup(host, { all }, (error, address) => resolve({ error, address })),
296
+ );
297
+ const local = await lookup("localhost", false);
298
+ expect(local.error?.message).toContain("private address");
299
+ const ip = await lookup("1.1.1.1", true);
300
+ expect(ip.error).toBeNull();
301
+ expect(ip.address).toEqual([{ address: "1.1.1.1", family: 4 }]);
302
+ });
303
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ * @feat:chat
4
+ */
5
+ import { cleanup, render } from "@testing-library/react";
6
+ import { afterEach, describe, expect, it } from "vitest";
7
+ import { ChatMarkdown, isSafeHref, parseMarkdown } from "../web/markdown";
8
+
9
+ afterEach(cleanup);
10
+
11
+ const UUID = "3f2b8c1e-0d4a-4c6b-9e1f-2a3b4c5d6e7f";
12
+
13
+ describe("parseMarkdown", () => {
14
+ it("reads bold, italic, code, links, mentions and line breaks", () => {
15
+ expect(parseMarkdown(`**hi** *there* _you_ \`x<y\` [site](https://example.com) @[Sam Lee](user:${UUID})\nnext`)).toEqual([
16
+ { type: "bold", children: [{ type: "text", text: "hi" }] },
17
+ { type: "text", text: " " },
18
+ { type: "italic", children: [{ type: "text", text: "there" }] },
19
+ { type: "text", text: " " },
20
+ { type: "italic", children: [{ type: "text", text: "you" }] },
21
+ { type: "text", text: " " },
22
+ { type: "code", text: "x<y" },
23
+ { type: "text", text: " " },
24
+ { type: "link", href: "https://example.com", children: [{ type: "text", text: "site" }] },
25
+ { type: "text", text: " " },
26
+ { type: "mention", name: "Sam Lee", userId: UUID },
27
+ { type: "break" },
28
+ { type: "text", text: "next" },
29
+ ]);
30
+ });
31
+
32
+ it("leaves snake_case, lone stars and unclosed markers as written", () => {
33
+ expect(parseMarkdown("my_var_name and 2 * 3 and **open")).toEqual([{ type: "text", text: "my_var_name and 2 * 3 and **open" }]);
34
+ });
35
+
36
+ it("links bare https URLs without their trailing punctuation", () => {
37
+ expect(parseMarkdown("see https://example.com/a?b=1.")).toEqual([
38
+ { type: "text", text: "see " },
39
+ { type: "link", href: "https://example.com/a?b=1", children: [{ type: "text", text: "https://example.com/a?b=1" }] },
40
+ { type: "text", text: "." },
41
+ ]);
42
+ });
43
+
44
+ it("keeps a link's words but drops a javascript:, data: or relative target", () => {
45
+ for (const href of ["javascript:alert(1)", "JavaScript:alert(1)", "data:text/html,<b>x</b>", "/relative", "vbscript:x"]) {
46
+ const nodes = parseMarkdown(`[click](${href})`);
47
+ expect(nodes.some((n) => n.type === "link")).toBe(false);
48
+ expect(nodes).toEqual([{ type: "text", text: "click" }]);
49
+ }
50
+ expect(isSafeHref("https://ok.example")).toBe(true);
51
+ expect(isSafeHref("http://ok.example")).toBe(true);
52
+ expect(isSafeHref("javascript:alert(1)")).toBe(false);
53
+ expect(isSafeHref("mailto:a@b.c")).toBe(false);
54
+ });
55
+
56
+ it("honours backslash escapes", () => {
57
+ expect(parseMarkdown("\\*not italic\\*")).toEqual([{ type: "text", text: "*not italic*" }]);
58
+ });
59
+ });
60
+
61
+ describe("ChatMarkdown", () => {
62
+ it("renders HTML in a message as text, never as elements", () => {
63
+ const { container } = render(<ChatMarkdown text={'<img src=x onerror="alert(1)"><script>alert(2)</script> **<b>bold</b>**'} />);
64
+ expect(container.querySelector("img")).toBeNull();
65
+ expect(container.querySelector("script")).toBeNull();
66
+ expect(container.querySelector("b")).toBeNull();
67
+ expect(container.textContent).toContain('<img src=x onerror="alert(1)">');
68
+ expect(container.querySelector("strong")?.textContent).toBe("<b>bold</b>");
69
+ });
70
+
71
+ it("opens links in a new tab with noopener noreferrer, and never a javascript: one", () => {
72
+ const { container } = render(<ChatMarkdown text="[ok](https://example.com) [bad](javascript:alert(1)) https://x.example" />);
73
+ const links = [...container.querySelectorAll("a")];
74
+ expect(links.map((a) => a.getAttribute("href"))).toEqual(["https://example.com", "https://x.example"]);
75
+ for (const a of links) {
76
+ expect(a.getAttribute("target")).toBe("_blank");
77
+ expect(a.getAttribute("rel")).toBe("noopener noreferrer");
78
+ }
79
+ expect(container.textContent).toContain("bad");
80
+ });
81
+
82
+ it("draws mentions as a chip, and line breaks as <br>", () => {
83
+ const { container } = render(<ChatMarkdown text={`hey @[Sam](user:${UUID})\nthere`} me={UUID} />);
84
+ const chip = container.querySelector(`[data-mention="${UUID}"]`);
85
+ expect(chip?.textContent).toBe("@Sam");
86
+ expect(container.querySelectorAll("br")).toHaveLength(1);
87
+ });
88
+ });