birdclaw 0.4.1 → 0.5.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 (64) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/README.md +108 -7
  3. package/package.json +24 -23
  4. package/playwright.config.ts +1 -0
  5. package/public/favicon.ico +0 -0
  6. package/public/logo192.png +0 -0
  7. package/public/logo512.png +0 -0
  8. package/public/manifest.json +2 -2
  9. package/src/cli.ts +450 -18
  10. package/src/components/AppNav.tsx +66 -29
  11. package/src/components/AvatarChip.tsx +10 -5
  12. package/src/components/ConversationThread.tsx +125 -0
  13. package/src/components/DmWorkspace.tsx +96 -98
  14. package/src/components/EmbeddedTweetCard.tsx +20 -14
  15. package/src/components/InboxCard.tsx +92 -90
  16. package/src/components/LinkPreviewCard.tsx +270 -0
  17. package/src/components/ProfilePreview.tsx +8 -3
  18. package/src/components/SavedTimelineView.tsx +48 -38
  19. package/src/components/TimelineCard.tsx +228 -84
  20. package/src/components/TweetRichText.tsx +6 -2
  21. package/src/lib/archive-import.ts +1565 -65
  22. package/src/lib/authored-live.ts +1074 -0
  23. package/src/lib/backup.ts +316 -14
  24. package/src/lib/bird-actions.ts +1 -10
  25. package/src/lib/bird-command.ts +57 -0
  26. package/src/lib/bird.ts +89 -5
  27. package/src/lib/config.ts +1 -1
  28. package/src/lib/db.ts +191 -4
  29. package/src/lib/follow-graph.ts +1053 -0
  30. package/src/lib/link-index.ts +11 -98
  31. package/src/lib/link-insights.ts +834 -0
  32. package/src/lib/link-preview-metadata.ts +334 -0
  33. package/src/lib/media-fetch.ts +787 -0
  34. package/src/lib/media-includes.ts +165 -0
  35. package/src/lib/mention-threads-live.ts +535 -43
  36. package/src/lib/mentions-live.ts +623 -19
  37. package/src/lib/moderation-target.ts +6 -0
  38. package/src/lib/profile-hydration.ts +1 -1
  39. package/src/lib/profile-resolver.ts +115 -1
  40. package/src/lib/queries.ts +233 -7
  41. package/src/lib/seed.ts +127 -8
  42. package/src/lib/timeline-collections-live.ts +145 -22
  43. package/src/lib/timeline-live.ts +10 -15
  44. package/src/lib/tweet-account-edges.ts +9 -2
  45. package/src/lib/tweet-render.ts +97 -0
  46. package/src/lib/types.ts +185 -2
  47. package/src/lib/ui.ts +375 -147
  48. package/src/lib/url-expansion-store.ts +110 -0
  49. package/src/lib/url-expansion.ts +20 -3
  50. package/src/lib/x-profile.ts +7 -2
  51. package/src/lib/xurl.ts +296 -12
  52. package/src/routeTree.gen.ts +105 -0
  53. package/src/routes/__root.tsx +7 -3
  54. package/src/routes/api/conversation.tsx +34 -0
  55. package/src/routes/api/link-insights.tsx +79 -0
  56. package/src/routes/api/link-preview.tsx +43 -0
  57. package/src/routes/api/profile-hydrate.tsx +51 -0
  58. package/src/routes/blocks.tsx +111 -86
  59. package/src/routes/dms.tsx +90 -78
  60. package/src/routes/inbox.tsx +98 -86
  61. package/src/routes/index.tsx +63 -50
  62. package/src/routes/links.tsx +883 -0
  63. package/src/routes/mentions.tsx +63 -50
  64. package/src/styles.css +106 -43
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { Route as rootRouteImport } from './routes/__root'
12
12
  import { Route as MentionsRouteImport } from './routes/mentions'
13
+ import { Route as LinksRouteImport } from './routes/links'
13
14
  import { Route as LikesRouteImport } from './routes/likes'
14
15
  import { Route as InboxRouteImport } from './routes/inbox'
15
16
  import { Route as DmsRouteImport } from './routes/dms'
@@ -18,7 +19,11 @@ import { Route as BlocksRouteImport } from './routes/blocks'
18
19
  import { Route as IndexRouteImport } from './routes/index'
19
20
  import { Route as ApiStatusRouteImport } from './routes/api/status'
20
21
  import { Route as ApiQueryRouteImport } from './routes/api/query'
22
+ import { Route as ApiProfileHydrateRouteImport } from './routes/api/profile-hydrate'
23
+ import { Route as ApiLinkPreviewRouteImport } from './routes/api/link-preview'
24
+ import { Route as ApiLinkInsightsRouteImport } from './routes/api/link-insights'
21
25
  import { Route as ApiInboxRouteImport } from './routes/api/inbox'
26
+ import { Route as ApiConversationRouteImport } from './routes/api/conversation'
22
27
  import { Route as ApiBlocksRouteImport } from './routes/api/blocks'
23
28
  import { Route as ApiAvatarRouteImport } from './routes/api/avatar'
24
29
  import { Route as ApiActionRouteImport } from './routes/api/action'
@@ -28,6 +33,11 @@ const MentionsRoute = MentionsRouteImport.update({
28
33
  path: '/mentions',
29
34
  getParentRoute: () => rootRouteImport,
30
35
  } as any)
36
+ const LinksRoute = LinksRouteImport.update({
37
+ id: '/links',
38
+ path: '/links',
39
+ getParentRoute: () => rootRouteImport,
40
+ } as any)
31
41
  const LikesRoute = LikesRouteImport.update({
32
42
  id: '/likes',
33
43
  path: '/likes',
@@ -68,11 +78,31 @@ const ApiQueryRoute = ApiQueryRouteImport.update({
68
78
  path: '/api/query',
69
79
  getParentRoute: () => rootRouteImport,
70
80
  } as any)
81
+ const ApiProfileHydrateRoute = ApiProfileHydrateRouteImport.update({
82
+ id: '/api/profile-hydrate',
83
+ path: '/api/profile-hydrate',
84
+ getParentRoute: () => rootRouteImport,
85
+ } as any)
86
+ const ApiLinkPreviewRoute = ApiLinkPreviewRouteImport.update({
87
+ id: '/api/link-preview',
88
+ path: '/api/link-preview',
89
+ getParentRoute: () => rootRouteImport,
90
+ } as any)
91
+ const ApiLinkInsightsRoute = ApiLinkInsightsRouteImport.update({
92
+ id: '/api/link-insights',
93
+ path: '/api/link-insights',
94
+ getParentRoute: () => rootRouteImport,
95
+ } as any)
71
96
  const ApiInboxRoute = ApiInboxRouteImport.update({
72
97
  id: '/api/inbox',
73
98
  path: '/api/inbox',
74
99
  getParentRoute: () => rootRouteImport,
75
100
  } as any)
101
+ const ApiConversationRoute = ApiConversationRouteImport.update({
102
+ id: '/api/conversation',
103
+ path: '/api/conversation',
104
+ getParentRoute: () => rootRouteImport,
105
+ } as any)
76
106
  const ApiBlocksRoute = ApiBlocksRouteImport.update({
77
107
  id: '/api/blocks',
78
108
  path: '/api/blocks',
@@ -96,11 +126,16 @@ export interface FileRoutesByFullPath {
96
126
  '/dms': typeof DmsRoute
97
127
  '/inbox': typeof InboxRoute
98
128
  '/likes': typeof LikesRoute
129
+ '/links': typeof LinksRoute
99
130
  '/mentions': typeof MentionsRoute
100
131
  '/api/action': typeof ApiActionRoute
101
132
  '/api/avatar': typeof ApiAvatarRoute
102
133
  '/api/blocks': typeof ApiBlocksRoute
134
+ '/api/conversation': typeof ApiConversationRoute
103
135
  '/api/inbox': typeof ApiInboxRoute
136
+ '/api/link-insights': typeof ApiLinkInsightsRoute
137
+ '/api/link-preview': typeof ApiLinkPreviewRoute
138
+ '/api/profile-hydrate': typeof ApiProfileHydrateRoute
104
139
  '/api/query': typeof ApiQueryRoute
105
140
  '/api/status': typeof ApiStatusRoute
106
141
  }
@@ -111,11 +146,16 @@ export interface FileRoutesByTo {
111
146
  '/dms': typeof DmsRoute
112
147
  '/inbox': typeof InboxRoute
113
148
  '/likes': typeof LikesRoute
149
+ '/links': typeof LinksRoute
114
150
  '/mentions': typeof MentionsRoute
115
151
  '/api/action': typeof ApiActionRoute
116
152
  '/api/avatar': typeof ApiAvatarRoute
117
153
  '/api/blocks': typeof ApiBlocksRoute
154
+ '/api/conversation': typeof ApiConversationRoute
118
155
  '/api/inbox': typeof ApiInboxRoute
156
+ '/api/link-insights': typeof ApiLinkInsightsRoute
157
+ '/api/link-preview': typeof ApiLinkPreviewRoute
158
+ '/api/profile-hydrate': typeof ApiProfileHydrateRoute
119
159
  '/api/query': typeof ApiQueryRoute
120
160
  '/api/status': typeof ApiStatusRoute
121
161
  }
@@ -127,11 +167,16 @@ export interface FileRoutesById {
127
167
  '/dms': typeof DmsRoute
128
168
  '/inbox': typeof InboxRoute
129
169
  '/likes': typeof LikesRoute
170
+ '/links': typeof LinksRoute
130
171
  '/mentions': typeof MentionsRoute
131
172
  '/api/action': typeof ApiActionRoute
132
173
  '/api/avatar': typeof ApiAvatarRoute
133
174
  '/api/blocks': typeof ApiBlocksRoute
175
+ '/api/conversation': typeof ApiConversationRoute
134
176
  '/api/inbox': typeof ApiInboxRoute
177
+ '/api/link-insights': typeof ApiLinkInsightsRoute
178
+ '/api/link-preview': typeof ApiLinkPreviewRoute
179
+ '/api/profile-hydrate': typeof ApiProfileHydrateRoute
135
180
  '/api/query': typeof ApiQueryRoute
136
181
  '/api/status': typeof ApiStatusRoute
137
182
  }
@@ -144,11 +189,16 @@ export interface FileRouteTypes {
144
189
  | '/dms'
145
190
  | '/inbox'
146
191
  | '/likes'
192
+ | '/links'
147
193
  | '/mentions'
148
194
  | '/api/action'
149
195
  | '/api/avatar'
150
196
  | '/api/blocks'
197
+ | '/api/conversation'
151
198
  | '/api/inbox'
199
+ | '/api/link-insights'
200
+ | '/api/link-preview'
201
+ | '/api/profile-hydrate'
152
202
  | '/api/query'
153
203
  | '/api/status'
154
204
  fileRoutesByTo: FileRoutesByTo
@@ -159,11 +209,16 @@ export interface FileRouteTypes {
159
209
  | '/dms'
160
210
  | '/inbox'
161
211
  | '/likes'
212
+ | '/links'
162
213
  | '/mentions'
163
214
  | '/api/action'
164
215
  | '/api/avatar'
165
216
  | '/api/blocks'
217
+ | '/api/conversation'
166
218
  | '/api/inbox'
219
+ | '/api/link-insights'
220
+ | '/api/link-preview'
221
+ | '/api/profile-hydrate'
167
222
  | '/api/query'
168
223
  | '/api/status'
169
224
  id:
@@ -174,11 +229,16 @@ export interface FileRouteTypes {
174
229
  | '/dms'
175
230
  | '/inbox'
176
231
  | '/likes'
232
+ | '/links'
177
233
  | '/mentions'
178
234
  | '/api/action'
179
235
  | '/api/avatar'
180
236
  | '/api/blocks'
237
+ | '/api/conversation'
181
238
  | '/api/inbox'
239
+ | '/api/link-insights'
240
+ | '/api/link-preview'
241
+ | '/api/profile-hydrate'
182
242
  | '/api/query'
183
243
  | '/api/status'
184
244
  fileRoutesById: FileRoutesById
@@ -190,11 +250,16 @@ export interface RootRouteChildren {
190
250
  DmsRoute: typeof DmsRoute
191
251
  InboxRoute: typeof InboxRoute
192
252
  LikesRoute: typeof LikesRoute
253
+ LinksRoute: typeof LinksRoute
193
254
  MentionsRoute: typeof MentionsRoute
194
255
  ApiActionRoute: typeof ApiActionRoute
195
256
  ApiAvatarRoute: typeof ApiAvatarRoute
196
257
  ApiBlocksRoute: typeof ApiBlocksRoute
258
+ ApiConversationRoute: typeof ApiConversationRoute
197
259
  ApiInboxRoute: typeof ApiInboxRoute
260
+ ApiLinkInsightsRoute: typeof ApiLinkInsightsRoute
261
+ ApiLinkPreviewRoute: typeof ApiLinkPreviewRoute
262
+ ApiProfileHydrateRoute: typeof ApiProfileHydrateRoute
198
263
  ApiQueryRoute: typeof ApiQueryRoute
199
264
  ApiStatusRoute: typeof ApiStatusRoute
200
265
  }
@@ -208,6 +273,13 @@ declare module '@tanstack/react-router' {
208
273
  preLoaderRoute: typeof MentionsRouteImport
209
274
  parentRoute: typeof rootRouteImport
210
275
  }
276
+ '/links': {
277
+ id: '/links'
278
+ path: '/links'
279
+ fullPath: '/links'
280
+ preLoaderRoute: typeof LinksRouteImport
281
+ parentRoute: typeof rootRouteImport
282
+ }
211
283
  '/likes': {
212
284
  id: '/likes'
213
285
  path: '/likes'
@@ -264,6 +336,27 @@ declare module '@tanstack/react-router' {
264
336
  preLoaderRoute: typeof ApiQueryRouteImport
265
337
  parentRoute: typeof rootRouteImport
266
338
  }
339
+ '/api/profile-hydrate': {
340
+ id: '/api/profile-hydrate'
341
+ path: '/api/profile-hydrate'
342
+ fullPath: '/api/profile-hydrate'
343
+ preLoaderRoute: typeof ApiProfileHydrateRouteImport
344
+ parentRoute: typeof rootRouteImport
345
+ }
346
+ '/api/link-preview': {
347
+ id: '/api/link-preview'
348
+ path: '/api/link-preview'
349
+ fullPath: '/api/link-preview'
350
+ preLoaderRoute: typeof ApiLinkPreviewRouteImport
351
+ parentRoute: typeof rootRouteImport
352
+ }
353
+ '/api/link-insights': {
354
+ id: '/api/link-insights'
355
+ path: '/api/link-insights'
356
+ fullPath: '/api/link-insights'
357
+ preLoaderRoute: typeof ApiLinkInsightsRouteImport
358
+ parentRoute: typeof rootRouteImport
359
+ }
267
360
  '/api/inbox': {
268
361
  id: '/api/inbox'
269
362
  path: '/api/inbox'
@@ -271,6 +364,13 @@ declare module '@tanstack/react-router' {
271
364
  preLoaderRoute: typeof ApiInboxRouteImport
272
365
  parentRoute: typeof rootRouteImport
273
366
  }
367
+ '/api/conversation': {
368
+ id: '/api/conversation'
369
+ path: '/api/conversation'
370
+ fullPath: '/api/conversation'
371
+ preLoaderRoute: typeof ApiConversationRouteImport
372
+ parentRoute: typeof rootRouteImport
373
+ }
274
374
  '/api/blocks': {
275
375
  id: '/api/blocks'
276
376
  path: '/api/blocks'
@@ -302,11 +402,16 @@ const rootRouteChildren: RootRouteChildren = {
302
402
  DmsRoute: DmsRoute,
303
403
  InboxRoute: InboxRoute,
304
404
  LikesRoute: LikesRoute,
405
+ LinksRoute: LinksRoute,
305
406
  MentionsRoute: MentionsRoute,
306
407
  ApiActionRoute: ApiActionRoute,
307
408
  ApiAvatarRoute: ApiAvatarRoute,
308
409
  ApiBlocksRoute: ApiBlocksRoute,
410
+ ApiConversationRoute: ApiConversationRoute,
309
411
  ApiInboxRoute: ApiInboxRoute,
412
+ ApiLinkInsightsRoute: ApiLinkInsightsRoute,
413
+ ApiLinkPreviewRoute: ApiLinkPreviewRoute,
414
+ ApiProfileHydrateRoute: ApiProfileHydrateRoute,
310
415
  ApiQueryRoute: ApiQueryRoute,
311
416
  ApiStatusRoute: ApiStatusRoute,
312
417
  }
@@ -4,7 +4,7 @@ import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
4
4
  import type { ReactNode } from "react";
5
5
  import { AppNav } from "#/components/AppNav";
6
6
  import { ThemeProvider, themeScript } from "#/lib/theme";
7
- import { bodyClass, pageWrapClass, siteShellClass } from "#/lib/ui";
7
+ import { bodyClass, mainColumnClass, siteShellClass } from "#/lib/ui";
8
8
 
9
9
  import appCss from "../styles.css?url";
10
10
 
@@ -34,7 +34,11 @@ export const Route = createRootRoute({
34
34
  });
35
35
 
36
36
  function NotFoundView() {
37
- return <main className={pageWrapClass}>Not Found</main>;
37
+ return (
38
+ <main className={mainColumnClass}>
39
+ <div className="px-4 py-10 text-[var(--ink-soft)]">Not Found</div>
40
+ </main>
41
+ );
38
42
  }
39
43
 
40
44
  function RootDocument({ children }: { children: ReactNode }) {
@@ -48,7 +52,7 @@ function RootDocument({ children }: { children: ReactNode }) {
48
52
  <ThemeProvider>
49
53
  <div className={siteShellClass}>
50
54
  <AppNav />
51
- {children}
55
+ <main className={mainColumnClass}>{children}</main>
52
56
  </div>
53
57
  </ThemeProvider>
54
58
  <TanStackDevtools
@@ -0,0 +1,34 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { maybeAutoUpdateBackup } from "#/lib/backup";
3
+ import { getTweetConversation } from "#/lib/queries";
4
+
5
+ function json(data: unknown, status = 200) {
6
+ return new Response(JSON.stringify(data), {
7
+ status,
8
+ headers: {
9
+ "content-type": "application/json",
10
+ },
11
+ });
12
+ }
13
+
14
+ export const Route = createFileRoute("/api/conversation")({
15
+ server: {
16
+ handlers: {
17
+ GET: async ({ request }) => {
18
+ await maybeAutoUpdateBackup();
19
+ const url = new URL(request.url);
20
+ const tweetId = url.searchParams.get("tweetId")?.trim();
21
+ if (!tweetId) {
22
+ return json({ ok: false, error: "Missing tweetId" }, 400);
23
+ }
24
+
25
+ const conversation = getTweetConversation(tweetId);
26
+ if (!conversation) {
27
+ return json({ ok: false, error: "Tweet not found" }, 404);
28
+ }
29
+
30
+ return json({ ok: true, ...conversation });
31
+ },
32
+ },
33
+ },
34
+ });
@@ -0,0 +1,79 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { maybeAutoUpdateBackup } from "#/lib/backup";
3
+ import { getNativeDb } from "#/lib/db";
4
+ import { getLinkInsights } from "#/lib/link-insights";
5
+ import type {
6
+ LinkInsightKind,
7
+ LinkInsightRange,
8
+ LinkInsightSort,
9
+ LinkInsightSource,
10
+ } from "#/lib/types";
11
+
12
+ function json(data: unknown) {
13
+ return new Response(JSON.stringify(data), {
14
+ headers: {
15
+ "content-type": "application/json",
16
+ },
17
+ });
18
+ }
19
+
20
+ function parseKind(value: string | null): LinkInsightKind {
21
+ return value === "videos" ? "videos" : "links";
22
+ }
23
+
24
+ function parseRange(value: string | null): LinkInsightRange {
25
+ if (
26
+ value === "today" ||
27
+ value === "week" ||
28
+ value === "month" ||
29
+ value === "year" ||
30
+ value === "all"
31
+ ) {
32
+ return value;
33
+ }
34
+ return "week";
35
+ }
36
+
37
+ function parseSource(value: string | null): LinkInsightSource {
38
+ if (value === "tweet" || value === "dm") {
39
+ return value;
40
+ }
41
+ return "all";
42
+ }
43
+
44
+ function parseSort(value: string | null): LinkInsightSort {
45
+ if (value === "recent" || value === "comments") {
46
+ return value;
47
+ }
48
+ return "rank";
49
+ }
50
+
51
+ function parseNumber(value: string | null) {
52
+ if (!value) return undefined;
53
+ const parsed = Number(value);
54
+ return Number.isFinite(parsed) ? parsed : undefined;
55
+ }
56
+
57
+ export const Route = createFileRoute("/api/link-insights")({
58
+ server: {
59
+ handlers: {
60
+ GET: async ({ request }) => {
61
+ await maybeAutoUpdateBackup();
62
+ getNativeDb();
63
+ const url = new URL(request.url);
64
+ return json(
65
+ getLinkInsights({
66
+ kind: parseKind(url.searchParams.get("kind")),
67
+ range: parseRange(url.searchParams.get("range")),
68
+ sort: parseSort(url.searchParams.get("sort")),
69
+ source: parseSource(url.searchParams.get("source")),
70
+ since: url.searchParams.get("since") ?? undefined,
71
+ until: url.searchParams.get("until") ?? undefined,
72
+ limit: parseNumber(url.searchParams.get("limit")),
73
+ commentsLimit: parseNumber(url.searchParams.get("commentsLimit")),
74
+ }),
75
+ );
76
+ },
77
+ },
78
+ },
79
+ });
@@ -0,0 +1,43 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { getOrFetchLinkPreview } from "#/lib/link-preview-metadata";
3
+
4
+ function json(data: unknown, init?: ResponseInit) {
5
+ return new Response(JSON.stringify(data), {
6
+ ...init,
7
+ headers: {
8
+ "content-type": "application/json",
9
+ ...init?.headers,
10
+ },
11
+ });
12
+ }
13
+
14
+ function parseUrl(value: string | null) {
15
+ if (!value) return null;
16
+ try {
17
+ const parsed = new URL(value);
18
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
19
+ return null;
20
+ }
21
+ return parsed.toString();
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ export const Route = createFileRoute("/api/link-preview")({
28
+ server: {
29
+ handlers: {
30
+ GET: async ({ request }) => {
31
+ const url = new URL(request.url);
32
+ const previewUrl = parseUrl(url.searchParams.get("url"));
33
+ const shortUrl = parseUrl(url.searchParams.get("shortUrl"));
34
+ if (!previewUrl) {
35
+ return json({ ok: false, message: "Missing url" }, { status: 400 });
36
+ }
37
+
38
+ const preview = await getOrFetchLinkPreview(previewUrl, { shortUrl });
39
+ return json({ ok: true, preview });
40
+ },
41
+ },
42
+ },
43
+ });
@@ -0,0 +1,51 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { resolveProfilesForHandles } from "#/lib/profile-resolver";
3
+
4
+ function json(data: unknown, init?: ResponseInit) {
5
+ return new Response(JSON.stringify(data), {
6
+ ...init,
7
+ headers: {
8
+ "content-type": "application/json",
9
+ ...init?.headers,
10
+ },
11
+ });
12
+ }
13
+
14
+ function parseHandles(url: URL) {
15
+ const rawValues = [
16
+ ...url.searchParams.getAll("handle"),
17
+ ...(url.searchParams.get("handles")?.split(",") ?? []),
18
+ ];
19
+ return Array.from(
20
+ new Set(
21
+ rawValues
22
+ .map((value) => value.trim().replace(/^@/, ""))
23
+ .filter((value) => /^[A-Za-z0-9_]{1,15}$/.test(value)),
24
+ ),
25
+ ).slice(0, 50);
26
+ }
27
+
28
+ export const Route = createFileRoute("/api/profile-hydrate")({
29
+ server: {
30
+ handlers: {
31
+ GET: async ({ request }) => {
32
+ const url = new URL(request.url);
33
+ const handles = parseHandles(url);
34
+ if (handles.length === 0) {
35
+ return json(
36
+ { ok: false, message: "Missing handles" },
37
+ { status: 400 },
38
+ );
39
+ }
40
+
41
+ const results = await resolveProfilesForHandles(handles);
42
+ return json({
43
+ ok: true,
44
+ results,
45
+ hydratedProfiles: results.filter((result) => result.status === "hit")
46
+ .length,
47
+ });
48
+ },
49
+ },
50
+ },
51
+ });