@marktiderman/genesis-chat 0.1.1 → 0.2.0

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 (162) hide show
  1. package/README.md +304 -13
  2. package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
  3. package/dist/chunk-5D5A7I4Q.js.map +1 -0
  4. package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
  5. package/dist/chunk-7S6HYNWW.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-PZTOY2LY.js +576 -0
  9. package/dist/chunk-PZTOY2LY.js.map +1 -0
  10. package/dist/chunk-VEPZROQB.js +187 -0
  11. package/dist/chunk-VEPZROQB.js.map +1 -0
  12. package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
  13. package/dist/index.d.ts +78 -163
  14. package/dist/index.js +3 -2
  15. package/dist/native.d.ts +1910 -1
  16. package/dist/native.js +4900 -0
  17. package/dist/native.js.map +1 -1
  18. package/dist/outbox-B0y3phE0.d.ts +151 -0
  19. package/dist/push.d.ts +206 -0
  20. package/dist/push.js +3 -0
  21. package/dist/push.js.map +1 -0
  22. package/dist/react.d.ts +149 -7
  23. package/dist/react.js +3 -3
  24. package/dist/server.d.ts +510 -21
  25. package/dist/server.js +863 -21
  26. package/dist/server.js.map +1 -1
  27. package/dist/thread-DWXHKPdw.d.ts +57 -0
  28. package/dist/web.d.ts +313 -10
  29. package/dist/web.js +1731 -60
  30. package/dist/web.js.map +1 -1
  31. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  32. package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
  33. package/package.json +74 -6
  34. package/src/__tests__/coaching-core.test.ts +332 -0
  35. package/src/__tests__/coaching-react.test.tsx +248 -0
  36. package/src/__tests__/coaching-web.test.tsx +449 -0
  37. package/src/__tests__/core.test.ts +91 -0
  38. package/src/__tests__/outbox.test.ts +122 -0
  39. package/src/__tests__/realtime.test.ts +70 -0
  40. package/src/__tests__/review-fixes-core.test.ts +193 -0
  41. package/src/__tests__/review-fixes-web.test.tsx +402 -0
  42. package/src/__tests__/server-effects.test.ts +158 -0
  43. package/src/__tests__/server-fakes.ts +90 -0
  44. package/src/__tests__/server-integrations.test.ts +247 -0
  45. package/src/__tests__/server-providers.test.ts +246 -0
  46. package/src/__tests__/server-runner.test.ts +277 -0
  47. package/src/__tests__/server-safety.test.ts +290 -0
  48. package/src/__tests__/web-markdown.test.tsx +88 -0
  49. package/src/__tests__/web.test.tsx +333 -0
  50. package/src/core/availability.ts +223 -0
  51. package/src/core/client.ts +757 -0
  52. package/src/core/extras.ts +106 -0
  53. package/src/core/media.ts +144 -0
  54. package/src/core/outbox.ts +429 -0
  55. package/src/core/thread.ts +171 -0
  56. package/src/core/types.ts +311 -0
  57. package/src/index.ts +13 -0
  58. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  59. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  60. package/src/native/__tests__/composer-state.test.ts +188 -0
  61. package/src/native/__tests__/composer.test.tsx +133 -0
  62. package/src/native/__tests__/emoji.test.tsx +238 -0
  63. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  64. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  65. package/src/native/__tests__/organise-logic.test.ts +164 -0
  66. package/src/native/__tests__/organise-screens.test.tsx +344 -0
  67. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  68. package/src/native/__tests__/push-payload.test.ts +66 -0
  69. package/src/native/__tests__/push.test.ts +209 -0
  70. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  71. package/src/native/__tests__/rows-actions.test.ts +76 -0
  72. package/src/native/__tests__/thread-layout.test.ts +41 -0
  73. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  74. package/src/native/__tests__/tus.test.ts +154 -0
  75. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  76. package/src/native/__tests__/video-media.test.tsx +421 -0
  77. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  78. package/src/native/__tests__/video-upload.test.ts +227 -0
  79. package/src/native/access-error.tsx +31 -0
  80. package/src/native/actions.ts +42 -0
  81. package/src/native/attachment-cards.tsx +116 -0
  82. package/src/native/auto-retry.ts +69 -0
  83. package/src/native/availability.tsx +376 -0
  84. package/src/native/broadcast-sheet.tsx +193 -0
  85. package/src/native/composer-state.ts +302 -0
  86. package/src/native/composer.tsx +453 -0
  87. package/src/native/context.tsx +70 -0
  88. package/src/native/copy.ts +296 -0
  89. package/src/native/emoji-data.ts +21 -0
  90. package/src/native/emoji-picker.tsx +229 -0
  91. package/src/native/emoji.ts +118 -0
  92. package/src/native/icon-button.tsx +40 -0
  93. package/src/native/inbox-screen.tsx +116 -0
  94. package/src/native/index.ts +184 -0
  95. package/src/native/link-preview.tsx +104 -0
  96. package/src/native/media-colors.ts +15 -0
  97. package/src/native/media.tsx +187 -0
  98. package/src/native/message-actions-sheet.tsx +121 -0
  99. package/src/native/message-bubble.tsx +374 -0
  100. package/src/native/moderation.tsx +42 -0
  101. package/src/native/modules.ts +275 -0
  102. package/src/native/organise-copy.ts +195 -0
  103. package/src/native/photo-viewer.tsx +531 -0
  104. package/src/native/push.ts +375 -0
  105. package/src/native/recorder-machine.ts +176 -0
  106. package/src/native/rows.ts +81 -0
  107. package/src/native/schedule-sheet.tsx +314 -0
  108. package/src/native/search-screen.tsx +168 -0
  109. package/src/native/stack-options.ts +36 -0
  110. package/src/native/start-sheet.tsx +87 -0
  111. package/src/native/thread-screen.tsx +620 -0
  112. package/src/native/topics.tsx +179 -0
  113. package/src/native/tus.ts +282 -0
  114. package/src/native/use-composer.ts +71 -0
  115. package/src/native/use-jump.ts +91 -0
  116. package/src/native/use-voice-recorder.ts +234 -0
  117. package/src/native/video-player.tsx +370 -0
  118. package/src/native/video-recorder-state.ts +140 -0
  119. package/src/native/video-recorder.tsx +271 -0
  120. package/src/native/video-upload-tray.tsx +77 -0
  121. package/src/native/video-upload.ts +432 -0
  122. package/src/native/voice-player.tsx +283 -0
  123. package/src/react/index.tsx +810 -0
  124. package/src/server/assistant.ts +235 -0
  125. package/src/server/crypto.ts +86 -0
  126. package/src/server/expo.ts +119 -0
  127. package/src/server/handler.ts +32 -0
  128. package/src/server/index.ts +57 -0
  129. package/src/server/moderation.ts +155 -0
  130. package/src/server/mux.ts +290 -0
  131. package/src/server/notifications.ts +130 -0
  132. package/src/server/runner.ts +468 -0
  133. package/src/server/transcribers.ts +111 -0
  134. package/src/server/types.ts +171 -0
  135. package/src/server/unfurl.ts +480 -0
  136. package/src/server/upload.ts +158 -0
  137. package/src/web/attachments.tsx +379 -0
  138. package/src/web/availability.tsx +299 -0
  139. package/src/web/broadcast.tsx +166 -0
  140. package/src/web/bubble.tsx +563 -0
  141. package/src/web/cards.tsx +136 -0
  142. package/src/web/composer.tsx +571 -0
  143. package/src/web/emoji-picker.tsx +84 -0
  144. package/src/web/error-boundary.tsx +33 -0
  145. package/src/web/format.ts +55 -0
  146. package/src/web/inbox.tsx +195 -0
  147. package/src/web/index.ts +75 -0
  148. package/src/web/markdown.tsx +245 -0
  149. package/src/web/memory-client.ts +706 -0
  150. package/src/web/panel.tsx +135 -0
  151. package/src/web/schedule.tsx +192 -0
  152. package/src/web/search.tsx +129 -0
  153. package/src/web/thread-context.ts +64 -0
  154. package/src/web/thread.tsx +802 -0
  155. package/src/web/topics.tsx +160 -0
  156. package/src/web/use-voice-recorder.ts +264 -0
  157. package/src/web/video.tsx +522 -0
  158. package/src/web/voice-note.tsx +197 -0
  159. package/dist/chunk-27UTR5FW.js +0 -322
  160. package/dist/chunk-27UTR5FW.js.map +0 -1
  161. package/dist/chunk-JQXZUEEL.js.map +0 -1
  162. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -0,0 +1,171 @@
1
+ // @feat:chat
2
+ /**
3
+ * The server's contracts: what the runner hands an app's push sender, and the
4
+ * adapters it takes for transcripts and hosted video. No React, no Node APIs:
5
+ * fetch and Web Crypto only, so it runs in Node, edge functions and Deno.
6
+ */
7
+ import type { ChatSupabase } from "../core/client";
8
+
9
+ /** One person a chat push goes to. */
10
+ export interface ChatRecipient {
11
+ userId: string;
12
+ /** Their unread total across conversations (capped at 99 each), for the app badge. */
13
+ badge: number;
14
+ /** The message mentions them. */
15
+ mentioned: boolean;
16
+ }
17
+
18
+ /**
19
+ * One message's push, for every recipient at once. Its shape matches what
20
+ * `notify()` from genesis-notifications takes (type/category, recipients, data,
21
+ * idempotencyKey, threadKey, collapseKey), so an app can hand it on unchanged.
22
+ */
23
+ export interface ChatNotification {
24
+ category: "messages";
25
+ messageId: string;
26
+ conversationId: string;
27
+ recipients: ChatRecipient[];
28
+ /** The conversation's title, or the sender's name in a direct conversation. */
29
+ title: string;
30
+ /** The preview; in a group, prefixed with the sender's name ("Ana: see you"). */
31
+ body: string;
32
+ senderId: string | null;
33
+ /**
34
+ * `chat:<conversation id>`, only when the runner's `collapse` is on: a newer push
35
+ * replaces an older one. Off by default, because on iOS it becomes the
36
+ * notification's identifier, so replies from two notifications of one
37
+ * conversation would collide.
38
+ */
39
+ collapseKey?: string | undefined;
40
+ /** The conversation id: groups a conversation's pushes on the lock screen. */
41
+ threadKey: string;
42
+ /** `push:<message id>`: the same message is never pushed twice by a sender that dedupes. */
43
+ idempotencyKey: string;
44
+ /**
45
+ * The push's data, built by `chatPushPayload` (genesis-chat/push): flat strings the iOS
46
+ * Notification Service Extension reads (sender name and https photo, group name) plus `seq`.
47
+ */
48
+ data: {
49
+ url: string;
50
+ conversationId: string;
51
+ messageId: string;
52
+ seq: number | null;
53
+ senderId?: string;
54
+ senderName?: string;
55
+ senderAvatar?: string;
56
+ groupName?: string;
57
+ };
58
+ /** Let the iOS extension rewrite it into a Communication Notification (sender photo). */
59
+ mutableContent?: boolean;
60
+ /** The notification category the native push block registers for chat (`messages`). */
61
+ categoryId?: string;
62
+ }
63
+
64
+ /**
65
+ * Sends a message's push. Throw to have the effect retried (with backoff, up
66
+ * to 8 attempts); resolve once it is handed over. What it resolves with is
67
+ * passed back to nobody: keep what you need (tickets) yourself.
68
+ */
69
+ export type ChatDeliver = (notification: ChatNotification) => Promise<unknown>;
70
+
71
+ /** A voice note or video to transcribe. */
72
+ export interface TranscribeInput {
73
+ /** A signed URL, valid for 10 minutes. */
74
+ url: string;
75
+ mime: string | null;
76
+ durationMs: number | null;
77
+ /** A BCP-47 code; without one the provider detects the language. */
78
+ language?: string | undefined;
79
+ }
80
+
81
+ export interface TranscribeResult {
82
+ text: string;
83
+ /** The language heard, when the provider says. */
84
+ language?: string | undefined;
85
+ }
86
+
87
+ /** A speech-to-text provider. Throw on failure: the effect is retried. */
88
+ export interface Transcriber {
89
+ readonly name: string;
90
+ transcribe(input: TranscribeInput): Promise<TranscribeResult>;
91
+ }
92
+
93
+ /** A hosted video service (Mux): direct uploads, removal and its webhook. */
94
+ export interface VideoProvider {
95
+ /** The attachments' `provider` value it owns (`mux`). */
96
+ readonly name: "mux" | "cloudflare";
97
+ /** A one-time URL the client uploads the file to. */
98
+ createUpload(options: { corsOrigin: string; passthrough?: string | undefined }): Promise<{ uploadId: string; url: string }>;
99
+ /** Removes a hosted asset. Already gone counts as removed. */
100
+ remove(assetId: string): Promise<void>;
101
+ /** Verifies and applies one of the service's webhooks. */
102
+ handleWebhook(request: Request, context: { supabase: ChatSupabase }): Promise<Response>;
103
+ }
104
+
105
+ /** What moderation decided: `hidden` takes a message from members' view, `flagged` files a report only. */
106
+ export type ModerationResult = "clean" | "flagged" | "hidden";
107
+
108
+ /** A message to moderate: its text and signed URLs (10 minutes) for its photos. */
109
+ export interface ModerateInput {
110
+ text: string;
111
+ imageUrls: string[];
112
+ }
113
+
114
+ export interface ModerationVerdict {
115
+ result: ModerationResult;
116
+ /** A short, safe reason for the report observers see ("harassment 0.93"). Never the message itself. */
117
+ reason?: string | undefined;
118
+ }
119
+
120
+ /** A moderation provider. Throw on failure: the effect is retried. */
121
+ export interface Moderator {
122
+ readonly name: string;
123
+ moderate(input: ModerateInput): Promise<ModerationVerdict>;
124
+ }
125
+
126
+ /** One link's Open Graph data, as `save_link_preview` stores it. */
127
+ export interface LinkPreview {
128
+ url: string;
129
+ /** `blocked`: the URL is not one the server may fetch (private address, not https). */
130
+ status: "ok" | "failed" | "blocked";
131
+ title?: string | null | undefined;
132
+ description?: string | null | undefined;
133
+ /** https only. */
134
+ imageUrl?: string | null | undefined;
135
+ siteName?: string | null | undefined;
136
+ }
137
+
138
+ /** Fetches a link's preview. Resolve `failed` or `blocked` rather than throw; a throw is retried. */
139
+ export interface LinkUnfurler {
140
+ unfurl(url: string): Promise<LinkPreview>;
141
+ }
142
+
143
+ /** The outbox kinds the runner works. */
144
+ export type ChatEffectKind = "push" | "transcribe" | "storage.remove" | "video.remove" | "moderate" | "unfurl";
145
+
146
+ /** A claimed row of `genesis_chat.effects`. */
147
+ export interface ChatEffect {
148
+ id: number;
149
+ kind: string;
150
+ key: string;
151
+ payload: Record<string, unknown>;
152
+ /** This attempt's number, from 1 (claim_effects counts it). */
153
+ attempts: number;
154
+ }
155
+
156
+ export interface ChatRunReport {
157
+ claimed: number;
158
+ done: number;
159
+ /** Failed this time; retried later with backoff until the 8th attempt. */
160
+ failed: number;
161
+ /**
162
+ * Scheduled messages that were due. `skipped`: not posted this run (refused, with the reason
163
+ * on the row; a passing error, tried again next run; or taken by another runner).
164
+ * `failed`: the call itself failed. Absent with `scheduled: false`.
165
+ */
166
+ scheduled?: { due: number; posted: number; skipped: number; failed: number } | undefined;
167
+ /** Messages removed past their kind's retention. Present only with `retention: true`. */
168
+ purged?: number | undefined;
169
+ /** Steps besides the outbox that failed this run (scheduled posting, retention), as short messages. */
170
+ errors?: string[] | undefined;
171
+ }
@@ -0,0 +1,480 @@
1
+ // @feat:chat
2
+ /**
3
+ * Link previews without letting a message aim the server at its own network.
4
+ *
5
+ * - https only, port 443, no user:password@; at most 3 redirects, each hop
6
+ * checked again (redirects are followed by hand, never by fetch);
7
+ * - the host must not be, or resolve to, a private, loopback, link-local,
8
+ * carrier-grade NAT, multicast, documentation or metadata address
9
+ * (169.254.169.254, fd00:ec2::254), nor an IPv6 form that carries one
10
+ * (::ffff:0:0/96 mapped and translated, NAT64, 6to4 2002::/16, Teredo),
11
+ * nor `localhost`, `*.localhost`, `*.local`, `*.internal` or a single-label
12
+ * name;
13
+ * - no URL whose query carries what looks like a credential (`token=`,
14
+ * `sig=`, `code=`, `X-Amz-Signature=` ...): fetching it could use up a
15
+ * one-time link;
16
+ * - HTML only (`text/html`, `application/xhtml+xml`), read up to `maxBytes`
17
+ * and no further; nothing is executed;
18
+ * - og:title, og:description, og:image (https only, resolved against the
19
+ * page, and its host held to the same address rules, else dropped),
20
+ * og:site_name, with twitter:* and <title> / meta description as
21
+ * fallbacks; entities decoded, whitespace collapsed, lengths capped to the
22
+ * columns of `link_previews`.
23
+ *
24
+ * DNS: the host is resolved first (Node's `node:dns`, Deno's `Deno.resolveDns`,
25
+ * or a `resolve` you pass), within the timeout, and refused if any address is
26
+ * private. Where nothing can resolve (some edge runtimes), every host that is
27
+ * not an IP literal is refused (`blocked`, logged once) unless you pass
28
+ * `allowUnresolved: true`, accepting that a public name pointing at a private
29
+ * address gets through.
30
+ *
31
+ * Rebinding: fetch resolves the name again, so a host that changes its answer
32
+ * between the check and the connection is not caught by the check alone. On
33
+ * Node, close that gap by connecting through the checked lookup: pass
34
+ * `dispatcher: new Agent({ connect: { lookup: guardedLookup } })` (`Agent` from
35
+ * `undici`), which refuses a private address at connect time. Elsewhere, run
36
+ * the runner where outbound traffic to private ranges is firewalled.
37
+ */
38
+ import type { LinkPreview, LinkUnfurler } from "./types";
39
+
40
+ export interface LinkUnfurlerOptions {
41
+ fetch?: typeof fetch | undefined;
42
+ /** For the whole fetch, redirects included (default 5000). */
43
+ timeoutMs?: number | undefined;
44
+ /** Bytes of HTML read at most (default 512 KiB). */
45
+ maxBytes?: number | undefined;
46
+ /** At most this many redirects (default 3). */
47
+ maxRedirects?: number | undefined;
48
+ /**
49
+ * The host's addresses. Default: `node:dns` or `Deno.resolveDns` where
50
+ * present; `null` means "can't resolve here" and only literal and local
51
+ * hosts are refused. Throwing means the name doesn't resolve: `failed`.
52
+ */
53
+ resolve?: ((hostname: string) => Promise<string[] | null>) | undefined;
54
+ /**
55
+ * Fetch a name even when nothing here can resolve it (default false: such a
56
+ * name is `blocked`). Only for a runner behind an egress firewall.
57
+ */
58
+ allowUnresolved?: boolean | undefined;
59
+ /**
60
+ * Passed to fetch as `dispatcher` (Node, undici): `new Agent({ connect: {
61
+ * lookup: guardedLookup } })` pins each connection to an address checked at
62
+ * connect time, which closes DNS rebinding.
63
+ */
64
+ dispatcher?: unknown;
65
+ /** Where a misconfiguration is reported (no resolver). Default `console.warn`. */
66
+ log?: ((line: string) => void) | undefined;
67
+ userAgent?: string | undefined;
68
+ }
69
+
70
+ const CAPS = { title: 300, description: 600, siteName: 120, url: 2000 };
71
+
72
+ // ── Addresses ────────────────────────────────────────────────────────────────
73
+
74
+ function ipv4Parts(ip: string): number[] | null {
75
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
76
+ if (!m) return null;
77
+ const parts = m.slice(1).map(Number);
78
+ return parts.every((n) => n >= 0 && n <= 255) ? parts : null;
79
+ }
80
+
81
+ function privateIpv4([a, b, c]: number[]): boolean {
82
+ return (
83
+ a === 0 || // this network
84
+ a === 10 ||
85
+ a === 127 ||
86
+ (a === 100 && b! >= 64 && b! <= 127) || // carrier-grade NAT
87
+ (a === 169 && b === 254) || // link-local, cloud metadata
88
+ (a === 172 && b! >= 16 && b! <= 31) ||
89
+ (a === 192 && b === 0 && (c === 0 || c === 2)) || // IETF protocol, TEST-NET-1
90
+ (a === 192 && b === 88 && c === 99) || // 6to4 relay anycast
91
+ (a === 192 && b === 168) ||
92
+ (a === 198 && (b === 18 || b === 19)) || // benchmarking
93
+ (a === 198 && b === 51 && c === 100) || // TEST-NET-2
94
+ (a === 203 && b === 0 && c === 113) || // TEST-NET-3
95
+ a! >= 224 // multicast, reserved, broadcast
96
+ );
97
+ }
98
+
99
+ /** An IPv6 address as eight 16-bit groups, or null. */
100
+ function ipv6Groups(ip: string): number[] | null {
101
+ let s = ip.toLowerCase();
102
+ const zone = s.indexOf("%");
103
+ if (zone >= 0) s = s.slice(0, zone);
104
+ let tail: number[] = [];
105
+ const lastColon = s.lastIndexOf(":");
106
+ const v4 = ipv4Parts(s.slice(lastColon + 1));
107
+ if (v4) {
108
+ tail = [(v4[0]! << 8) | v4[1]!, (v4[2]! << 8) | v4[3]!];
109
+ s = s.slice(0, lastColon + 1) + "0:0";
110
+ }
111
+ const halves = s.split("::");
112
+ if (halves.length > 2) return null;
113
+ const parse = (part: string) => (part === "" ? [] : part.split(":"));
114
+ const head = parse(halves[0]!);
115
+ const rest = halves.length === 2 ? parse(halves[1]!) : [];
116
+ const missing = 8 - head.length - rest.length;
117
+ if (halves.length === 1 ? missing !== 0 : missing < 1) return null;
118
+ const all = [...head, ...Array<string>(halves.length === 2 ? missing : 0).fill("0"), ...rest];
119
+ if (all.length !== 8 || !all.every((g) => /^[0-9a-f]{1,4}$/.test(g))) return null;
120
+ const groups = all.map((g) => parseInt(g, 16));
121
+ if (tail.length) groups.splice(6, 2, ...tail);
122
+ return groups;
123
+ }
124
+
125
+ function privateIpv6(g: number[]): boolean {
126
+ const zeroUntil = (n: number) => g.slice(0, n).every((x) => x === 0);
127
+ if (zeroUntil(8)) return true; // ::
128
+ if (zeroUntil(7) && g[7] === 1) return true; // ::1
129
+ // IPv4-mapped (::ffff:a.b.c.d), IPv4-compatible (::a.b.c.d), IPv4-translated
130
+ // (::ffff:0:a.b.c.d) and NAT64 (64:ff9b::/96): judge the IPv4 inside.
131
+ const embedded = [g[6]! >> 8, g[6]! & 255, g[7]! >> 8, g[7]! & 255];
132
+ if (zeroUntil(5) && (g[5] === 0xffff || g[5] === 0)) return privateIpv4(embedded);
133
+ if (zeroUntil(4) && g[4] === 0xffff && g[5] === 0) return privateIpv4(embedded);
134
+ if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((x) => x === 0)) return privateIpv4(embedded);
135
+ // 6to4 (2002:a.b.c.d::/48): judge the IPv4 in groups 1 and 2.
136
+ if (g[0] === 0x2002) return privateIpv4([g[1]! >> 8, g[1]! & 255, g[2]! >> 8, g[2]! & 255]);
137
+ const first = g[0]!;
138
+ return (
139
+ (first === 0x64 && g[1] === 0xff9b && g[2] === 1) || // local-use NAT64 64:ff9b:1::/48
140
+ (first === 0x2001 && g[1]! < 0x0200) || // IETF protocol assignments 2001::/23 (Teredo 2001::/32 among them)
141
+ (first === 0x3fff && g[1]! < 0x1000) || // documentation 3fff::/20
142
+
143
+ (first & 0xfe00) === 0xfc00 || // unique local fc00::/7 (fd00:ec2::254 among them)
144
+ (first & 0xffc0) === 0xfe80 || // link-local
145
+ (first & 0xffc0) === 0xfec0 || // site-local (deprecated)
146
+ (first & 0xff00) === 0xff00 || // multicast
147
+ (first === 0x2001 && g[1] === 0x0db8) || // documentation
148
+ (first === 0x0100 && g[1] === 0 && g[2] === 0 && g[3] === 0) // discard 100::/64
149
+ );
150
+ }
151
+
152
+ /** True when `ip` (v4 or v6, brackets allowed) is not a public unicast address. Unparseable counts as private. */
153
+ export function isPrivateAddress(ip: string): boolean {
154
+ const bare = ip.replace(/^\[|\]$/g, "");
155
+ const v4 = ipv4Parts(bare);
156
+ if (v4) return privateIpv4(v4);
157
+ const v6 = ipv6Groups(bare);
158
+ return v6 ? privateIpv6(v6) : true;
159
+ }
160
+
161
+ function isIpLiteral(hostname: string): boolean {
162
+ const bare = hostname.replace(/^\[|\]$/g, "");
163
+ return ipv4Parts(bare) !== null || bare.includes(":");
164
+ }
165
+
166
+ function localName(hostname: string): boolean {
167
+ const h = hostname.toLowerCase().replace(/\.$/, "");
168
+ return !h.includes(".") || h === "localhost" || /\.(localhost|local|internal|home\.arpa|lan)$/.test(h);
169
+ }
170
+
171
+ type Resolve = (hostname: string) => Promise<string[] | null>;
172
+
173
+ /** Query parameters that carry a credential: a link holding one is never fetched. */
174
+ const CREDENTIAL_PARAM =
175
+ /[?&#;](access_token|id_token|refresh_token|token|auth|code|key|api_key|apikey|sig|signature|x-amz-signature|x-amz-credential|x-goog-signature|x-goog-credential|otp|secret|password|session|sessionid)=/i;
176
+
177
+ /** True when the URL's query or fragment carries what looks like a credential (exported for tests). */
178
+ export function carriesCredential(url: string): boolean {
179
+ return CREDENTIAL_PARAM.test(url);
180
+ }
181
+
182
+ type LookupAddress = { address: string; family: number };
183
+ type LookupCallback = (error: Error | null, address?: string | LookupAddress[], family?: number) => void;
184
+ type NodeDns = { lookup(hostname: string, options: Record<string, unknown>, callback: (e: Error | null, a: LookupAddress[]) => void): void };
185
+ let nodeDns: Promise<NodeDns> | null = null;
186
+
187
+ /**
188
+ * A `dns.lookup` for Node's socket `connect.lookup` (undici `Agent({ connect: { lookup } })`):
189
+ * resolves the name and refuses it when any address is private, so the connection
190
+ * goes only to an address that was checked. That closes DNS rebinding.
191
+ */
192
+ export function guardedLookup(hostname: string, options: { all?: boolean; family?: number } | number | undefined, callback: LookupCallback): void {
193
+ const opts = typeof options === "number" ? { family: options } : (options ?? {});
194
+ // A variable specifier keeps bundlers for edge runtimes from resolving it.
195
+ const specifier = "node:dns";
196
+ nodeDns ??= import(/* @vite-ignore */ specifier) as Promise<NodeDns>;
197
+ nodeDns
198
+ .then((dns) =>
199
+ dns.lookup(hostname, { ...opts, all: true, verbatim: true }, (error, addresses) => {
200
+ if (error) return callback(error);
201
+ if (addresses.length === 0 || addresses.some((a) => isPrivateAddress(a.address))) {
202
+ return callback(Object.assign(new Error(`genesis-chat: ${hostname} resolves to a private address`), { code: "EADDRNOTAVAIL" }));
203
+ }
204
+ if (opts.all) return callback(null, addresses);
205
+ return callback(null, addresses[0]!.address, addresses[0]!.family);
206
+ }),
207
+ )
208
+ .catch((e: unknown) => callback(e instanceof Error ? e : new Error(String(e))));
209
+ }
210
+
211
+ /** `promise`, or a rejection once `signal` aborts (a lookup can hang past fetch's timeout). */
212
+ function withSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
213
+ if (signal.aborted) return Promise.reject(new Error("timed out"));
214
+ return new Promise<T>((resolve, reject) => {
215
+ const abort = () => reject(new Error("timed out"));
216
+ signal.addEventListener("abort", abort, { once: true });
217
+ promise.then(
218
+ (v) => (signal.removeEventListener("abort", abort), resolve(v)),
219
+ (e: unknown) => (signal.removeEventListener("abort", abort), reject(e instanceof Error ? e : new Error(String(e)))),
220
+ );
221
+ });
222
+ }
223
+
224
+ /** Node's or Deno's resolver, or null where neither is there. */
225
+ async function runtimeResolver(): Promise<Resolve | null> {
226
+ const deno = (globalThis as { Deno?: { resolveDns?: (h: string, t: "A" | "AAAA") => Promise<string[]> } }).Deno;
227
+ if (deno?.resolveDns) {
228
+ const resolveDns = deno.resolveDns;
229
+ return async (h) => {
230
+ const [a, aaaa] = await Promise.all([resolveDns(h, "A").catch(() => []), resolveDns(h, "AAAA").catch(() => [])]);
231
+ if (a.length + aaaa.length === 0) throw new Error("no addresses");
232
+ return [...a, ...aaaa];
233
+ };
234
+ }
235
+ try {
236
+ // A variable specifier keeps bundlers for edge runtimes from resolving it.
237
+ const specifier = "node:dns";
238
+ const dns = (await import(/* @vite-ignore */ specifier)) as {
239
+ promises?: { lookup(h: string, o: { all: true; verbatim: boolean }): Promise<{ address: string }[]> };
240
+ };
241
+ const lookup = dns.promises?.lookup;
242
+ if (!lookup) return null;
243
+ return async (h) => (await lookup.call(dns.promises, h, { all: true, verbatim: true })).map((r) => r.address);
244
+ } catch {
245
+ return null;
246
+ }
247
+ }
248
+
249
+ // ── HTML ─────────────────────────────────────────────────────────────────────
250
+
251
+ const NAMED: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", ndash: "–", mdash: "—", hellip: "…", rsquo: "’", lsquo: "‘", rdquo: "”", ldquo: "“", copy: "©", reg: "®", trade: "™" };
252
+
253
+ /** Decodes HTML entities (named, decimal, hex). */
254
+ export function decodeEntities(text: string): string {
255
+ return text.replace(/&(#x[0-9a-f]{1,6}|#\d{1,7}|[a-z]{2,8});/gi, (whole, ref: string) => {
256
+ if (ref[0] === "#") {
257
+ const code = ref[1] === "x" || ref[1] === "X" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
258
+ return code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : "";
259
+ }
260
+ return NAMED[ref.toLowerCase()] ?? whole;
261
+ });
262
+ }
263
+
264
+ function clean(value: string | undefined, cap: number): string | null {
265
+ if (!value) return null;
266
+ // eslint-disable-next-line no-control-regex
267
+ const text = decodeEntities(value.replace(/<[^<>]*>/g, " ")).replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
268
+ if (!text) return null;
269
+ return text.length > cap ? `${text.slice(0, cap - 1).trimEnd()}…` : text;
270
+ }
271
+
272
+ function attributes(tag: string): Record<string, string> {
273
+ const out: Record<string, string> = {};
274
+ // A bounded name and a quote that may stay open keep this linear on a hostile tag.
275
+ for (const m of tag.matchAll(/([a-zA-Z_:][-a-zA-Z0-9_:.]{0,63})\s*=\s*(?:"([^"]*)"?|'([^']*)'?|([^\s"'=<>`]+))/g)) {
276
+ const name = m[1]!.toLowerCase();
277
+ if (!(name in out)) out[name] = m[2] ?? m[3] ?? m[4] ?? "";
278
+ }
279
+ return out;
280
+ }
281
+
282
+ /**
283
+ * `text` without each span from `open` to `close` (global regexes); an unclosed span runs to the
284
+ * end, as in a browser. One forward pass: a page full of unclosed `<script`s costs linear time,
285
+ * not a rescan to the end from each one.
286
+ */
287
+ function stripSpans(text: string, open: RegExp, close: RegExp): string {
288
+ let out = "";
289
+ let from = 0;
290
+ for (;;) {
291
+ open.lastIndex = from;
292
+ const o = open.exec(text);
293
+ if (!o) return out + text.slice(from);
294
+ out += text.slice(from, o.index);
295
+ close.lastIndex = o.index + o[0].length;
296
+ const c = close.exec(text);
297
+ if (!c) return out;
298
+ from = c.index + c[0].length;
299
+ }
300
+ }
301
+
302
+ /** The text from each match of `open` (global) to the next `>`, in one forward pass. */
303
+ function* tags(text: string, open: RegExp): Generator<string> {
304
+ open.lastIndex = 0;
305
+ for (let o = open.exec(text); o; o = open.exec(text)) {
306
+ const end = text.indexOf(">", o.index);
307
+ if (end < 0) return;
308
+ yield text.slice(o.index, end + 1);
309
+ open.lastIndex = end + 1;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Open Graph data from a page's HTML (exported for tests). `base` resolves a relative og:image.
315
+ * The page is hostile input and this runs synchronously, so every scan is linear in its length.
316
+ */
317
+ export function parseOpenGraph(html: string, base: string): Omit<LinkPreview, "url" | "status"> {
318
+ const headEnd = html.search(/<\/head\s*>/i);
319
+ const head = stripSpans(stripSpans(headEnd >= 0 ? html.slice(0, headEnd) : html, /<!--/g, /-->/g), /<script\b/gi, /<\/script\s*>/gi);
320
+ const meta: Record<string, string> = {};
321
+ for (const tag of tags(head, /<meta\b/gi)) {
322
+ const a = attributes(tag);
323
+ const key = (a.property ?? a.name ?? "").toLowerCase();
324
+ if (key && a.content !== undefined && !(key in meta)) meta[key] = a.content;
325
+ }
326
+ let titleTag: string | undefined;
327
+ const titleOpen = /<title\b/i.exec(head);
328
+ const titleStart = titleOpen ? head.indexOf(">", titleOpen.index) + 1 : 0;
329
+ if (titleStart > 0) {
330
+ const close = /<\/title\s*>/gi;
331
+ close.lastIndex = titleStart;
332
+ const c = close.exec(head);
333
+ if (c) titleTag = head.slice(titleStart, c.index);
334
+ }
335
+ let imageUrl: string | null = null;
336
+ const rawImage = meta["og:image:secure_url"] ?? meta["og:image"] ?? meta["og:image:url"] ?? meta["twitter:image"];
337
+ if (rawImage) {
338
+ try {
339
+ const resolved = new URL(decodeEntities(rawImage.trim()), base);
340
+ if (resolved.protocol === "https:" && resolved.href.length <= CAPS.url && !resolved.username && !resolved.password) imageUrl = resolved.href;
341
+ } catch {
342
+ imageUrl = null;
343
+ }
344
+ }
345
+ return {
346
+ title: clean(meta["og:title"] ?? meta["twitter:title"] ?? titleTag, CAPS.title),
347
+ description: clean(meta["og:description"] ?? meta["twitter:description"] ?? meta.description, CAPS.description),
348
+ imageUrl,
349
+ siteName: clean(meta["og:site_name"], CAPS.siteName),
350
+ };
351
+ }
352
+
353
+ // ── Fetching ─────────────────────────────────────────────────────────────────
354
+
355
+ async function readCapped(response: Response, maxBytes: number): Promise<string> {
356
+ const reader = response.body?.getReader();
357
+ if (!reader) return "";
358
+ const chunks: Uint8Array[] = [];
359
+ let total = 0;
360
+ while (total < maxBytes) {
361
+ const { done, value } = await reader.read();
362
+ if (done) break;
363
+ const room = maxBytes - total;
364
+ chunks.push(value.byteLength > room ? value.subarray(0, room) : value);
365
+ total += Math.min(value.byteLength, room);
366
+ }
367
+ await reader.cancel().catch(() => {});
368
+ const bytes = new Uint8Array(total);
369
+ let at = 0;
370
+ for (const c of chunks) {
371
+ bytes.set(c, at);
372
+ at += c.byteLength;
373
+ }
374
+ return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
375
+ }
376
+
377
+ export function createLinkUnfurler(options: LinkUnfurlerOptions = {}): LinkUnfurler {
378
+ const fetchFn = options.fetch ?? fetch;
379
+ const timeoutMs = options.timeoutMs ?? 5000;
380
+ const maxBytes = options.maxBytes ?? 512 * 1024;
381
+ const maxRedirects = options.maxRedirects ?? 3;
382
+ const allowUnresolved = options.allowUnresolved === true;
383
+ const log = options.log ?? ((line: string) => console.warn(line));
384
+ let warned = false;
385
+ let resolver: Promise<Resolve | null> | null = options.resolve ? Promise.resolve(options.resolve) : null;
386
+
387
+ /** A name nothing here could resolve: refused unless the app opted in. */
388
+ function unresolved(): "blocked" | null {
389
+ if (allowUnresolved) return null;
390
+ if (!warned) {
391
+ warned = true;
392
+ log("genesis-chat: link previews can't resolve DNS here, so every named host is blocked; pass `resolve`, or `allowUnresolved: true` behind an egress firewall");
393
+ }
394
+ return "blocked";
395
+ }
396
+
397
+ /** null when the URL may be fetched; otherwise why not. */
398
+ async function refuse(url: URL, signal: AbortSignal): Promise<"blocked" | "failed" | null> {
399
+ if (url.protocol !== "https:" || url.username || url.password) return "blocked";
400
+ if (url.port && url.port !== "443") return "blocked";
401
+ const host = url.hostname;
402
+ if (isIpLiteral(host)) return isPrivateAddress(host) ? "blocked" : null;
403
+ if (localName(host)) return "blocked";
404
+ resolver ??= runtimeResolver();
405
+ const resolve = await resolver;
406
+ if (!resolve) return unresolved();
407
+ let addresses: string[] | null;
408
+ try {
409
+ addresses = await withSignal(resolve(host), signal);
410
+ } catch {
411
+ return "failed";
412
+ }
413
+ if (addresses === null) return unresolved();
414
+ if (addresses.length === 0) return "failed";
415
+ return addresses.some(isPrivateAddress) ? "blocked" : null;
416
+ }
417
+
418
+ return {
419
+ async unfurl(input) {
420
+ const result = (status: LinkPreview["status"]): LinkPreview => ({ url: input, status });
421
+ let url: URL;
422
+ try {
423
+ url = new URL(input);
424
+ } catch {
425
+ return result("blocked");
426
+ }
427
+ const signal = AbortSignal.timeout(timeoutMs);
428
+ try {
429
+ for (let hop = 0; ; hop++) {
430
+ if (carriesCredential(url.href)) return result("blocked");
431
+ const refused = await refuse(url, signal);
432
+ if (refused) return result(refused);
433
+ const response = await fetchFn(url.href, {
434
+ redirect: "manual",
435
+ signal,
436
+ ...(options.dispatcher ? ({ dispatcher: options.dispatcher } as RequestInit) : {}),
437
+ headers: {
438
+ accept: "text/html,application/xhtml+xml;q=0.9",
439
+ "user-agent": options.userAgent ?? "GenesisChat-LinkPreview/1.0",
440
+ },
441
+ });
442
+ if (response.status >= 300 && response.status < 400) {
443
+ await response.body?.cancel().catch(() => {});
444
+ const location = response.headers.get("location");
445
+ if (!location || hop >= maxRedirects) return result("failed");
446
+ try {
447
+ url = new URL(location, url);
448
+ } catch {
449
+ return result("failed");
450
+ }
451
+ continue;
452
+ }
453
+ if (!response.ok) {
454
+ await response.body?.cancel().catch(() => {});
455
+ return result("failed");
456
+ }
457
+ const type = (response.headers.get("content-type") ?? "").toLowerCase();
458
+ if (!/^\s*(text\/html|application\/xhtml\+xml)\b/.test(type)) {
459
+ await response.body?.cancel().catch(() => {});
460
+ return result("failed");
461
+ }
462
+ const declared = Number(response.headers.get("content-length") ?? "");
463
+ if (Number.isFinite(declared) && declared > maxBytes) {
464
+ await response.body?.cancel().catch(() => {});
465
+ return result("failed");
466
+ }
467
+ const html = await readCapped(response, maxBytes);
468
+ const og = parseOpenGraph(html, url.href);
469
+ // Every recipient's device loads the image: hold its host to the same rules, or drop it.
470
+ if (og.imageUrl && (await refuse(new URL(og.imageUrl), signal))) og.imageUrl = null;
471
+ if (!og.title && !og.description && !og.imageUrl) return result("failed");
472
+ return { url: input, status: "ok", ...og };
473
+ }
474
+ } catch {
475
+ // Timeouts, resets, TLS errors: the page isn't there for us.
476
+ return result("failed");
477
+ }
478
+ },
479
+ };
480
+ }