diff --git a/packages/frontpage/app/(app)/feed/page.tsx b/packages/frontpage/app/(app)/feed/page.tsx
index bb77d735..392f1954 100644
--- a/packages/frontpage/app/(app)/feed/page.tsx
+++ b/packages/frontpage/app/(app)/feed/page.tsx
@@ -1,5 +1,10 @@
-import { getFeed } from "@/lib/data/feed-resolver";
+import { isDid } from "@/lib/data/atproto/did";
+import { getVerifiedHandle } from "@/lib/data/atproto/identity";
+import { resolveFeed } from "@/lib/data/feed-resolver";
+import { getMoreFeedPostsAction } from "@/lib/feed-action";
+import { InfiniteList } from "@/lib/infinite-list";
import { AtUri } from "@atproto/syntax";
+import Link from "next/link";
import { redirect } from "next/navigation";
export default async function FeedPage({ searchParams }: PageProps<"/feed">) {
@@ -15,7 +20,43 @@ export default async function FeedPage({ searchParams }: PageProps<"/feed">) {
return
Invalid URI
;
}
- const posts = await getFeed(uri);
+ const [initialData, feedResult] = await Promise.all([
+ getMoreFeedPostsAction(uri.toString(), null),
+ resolveFeed(uri),
+ ]);
- return {JSON.stringify(posts, null, 2)};
+ if (!feedResult.ok) {
+ return (
+ Error loading feed: {feedResult.error.message}
+ );
+ }
+
+ const handle = isDid(uri.host)
+ ? ((await getVerifiedHandle(uri.host)) ?? "handle.invalid")
+ : uri.host;
+
+ const { displayName, description } = feedResult.data;
+
+ return (
+
+
+
{displayName}
+
+
+ @{handle}
+
+
+ {description ?
{description}
: null}
+
+
+
+ );
}
diff --git a/packages/frontpage/lib/data/feed-resolver.ts b/packages/frontpage/lib/data/feed-resolver.ts
index 045e6b37..ab1daa31 100644
--- a/packages/frontpage/lib/data/feed-resolver.ts
+++ b/packages/frontpage/lib/data/feed-resolver.ts
@@ -1,11 +1,15 @@
import "server-only";
import type { AtUri } from "@atproto/syntax";
-import { getDidDoc, parseDid, type DID } from "@/lib/data/atproto/did";
+import {
+ getDidDoc,
+ getPdsUrl,
+ parseDid,
+ type DID,
+} from "@/lib/data/atproto/did";
import { getLocalFeedSkeleton } from "@/lib/data/db/feed-skeleton";
import { hydratePosts, type HydratedPost } from "@/lib/data/db/post";
import { assertPublicHostname } from "@/lib/data/ssrf";
-import { invariant } from "@/lib/utils";
import {
FyiFrontpageFeedGenerator,
type FyiFrontpageFeedGetFeedSkeleton,
@@ -15,6 +19,8 @@ import { FEED_REGISTRY, type FeedSlug } from "@/lib/feed-constants";
import { getAtprotoClient, nsids } from "@/lib/data/atproto/repo";
import { publicConfig } from "../config/public-config";
import { FRONTPAGE_ATPROTO_HANDLE } from "../constants";
+import { getDidFromHandleOrDid } from "./atproto/identity";
+import { cache } from "react";
export type FeedError =
| { code: "InvalidCollection"; message: string }
@@ -22,13 +28,17 @@ export type FeedError =
| { code: "ExternalError"; message: string }
| { code: "InvalidResponse"; message: string };
-export type FeedSkeletonResult =
- | { ok: true; data: FyiFrontpageFeedGetFeedSkeleton.OutputSchema }
- | { ok: false; error: FeedError };
+type Result = { ok: true; data: T } | { ok: false; error: E };
+
+export type FeedSkeletonResult = Result<
+ FyiFrontpageFeedGetFeedSkeleton.OutputSchema,
+ FeedError
+>;
-export type FeedResult =
- | { ok: true; posts: HydratedPost[]; cursor?: string }
- | { ok: false; error: FeedError };
+export type FeedResult = Result<
+ { posts: HydratedPost[]; cursor?: string },
+ FeedError
+>;
// Internal page size for server action callers. The XRPC route uses its own
// DEFAULT_SKELETON_LIMIT and always passes an explicit limit, so this default
@@ -43,7 +53,7 @@ export async function getFeed(
const posts = await hydratePosts(skeletonResult.data.feed.map((s) => s.post));
- return { ok: true, posts, cursor: skeletonResult.data.cursor };
+ return { ok: true, data: { posts, cursor: skeletonResult.data.cursor } };
}
async function getFeedSkeleton(
@@ -105,18 +115,17 @@ async function getExternalSkeleton(
cursor: string | undefined,
limit: number,
): Promise {
- const generatorRecord = await fetchGeneratorRecord(feedUri);
- const serviceDid = parseDid(generatorRecord.did);
- invariant(
- serviceDid,
- `Generator record contains invalid DID: ${generatorRecord.did}`,
- );
-
- const serviceEndpoint = await resolveServiceEndpoint(serviceDid);
+ const generatorResult = await resolveFeed(feedUri);
+ if (!generatorResult.ok) {
+ return {
+ ok: false,
+ error: generatorResult.error,
+ };
+ }
const url = new URL(
`/xrpc/${nsids.FyiFrontpageFeedGetFeedSkeleton}`,
- serviceEndpoint,
+ generatorResult.data.service,
);
url.searchParams.set("feed", feedUri.toString());
url.searchParams.set("limit", String(limit));
@@ -160,41 +169,129 @@ async function getExternalSkeleton(
};
}
-async function fetchGeneratorRecord(feedUri: AtUri): Promise<{ did: string }> {
- const client = getAtprotoClient();
-
- const result = await client.com.atproto.repo.getRecord({
- repo: feedUri.host,
- collection: feedUri.collection,
- rkey: feedUri.rkey,
- });
-
- const validated = FyiFrontpageFeedGenerator.validateRecord(result.data.value);
- invariant(validated.success, "Invalid generator record");
-
- return { did: validated.value.did };
-}
+type FeedResolution = {
+ serviceDid: DID;
+ service: string;
+ displayName: string;
+ description?: string;
+};
+
+export const resolveFeed = cache(
+ async (feedUri: AtUri): Promise> => {
+ const generatorDid = await getDidFromHandleOrDid(feedUri.host);
+ if (!generatorDid) {
+ return {
+ ok: false,
+ error: {
+ code: "UnknownFeed",
+ message: `Could not resolve DID for feed generator ${feedUri.host}`,
+ },
+ };
+ }
+ const generatorPdsUrl = await getPdsUrl(generatorDid);
+ if (!generatorPdsUrl) {
+ return {
+ ok: false,
+ error: {
+ code: "UnknownFeed",
+ message: `Could not find PDS for feed generator DID ${generatorDid}`,
+ },
+ };
+ }
+ const client = getAtprotoClient(generatorPdsUrl);
+
+ const result = await client.com.atproto.repo.getRecord({
+ repo: feedUri.host,
+ collection: feedUri.collection,
+ rkey: feedUri.rkey,
+ });
+
+ const validated = FyiFrontpageFeedGenerator.validateRecord(
+ result.data.value,
+ );
+ if (!validated.success) {
+ return {
+ ok: false,
+ error: {
+ code: "InvalidResponse",
+ message: `Feed generator record failed validation: ${validated.error.message}`,
+ },
+ };
+ }
+
+ const serviceDid = parseDid(validated.value.did);
+ if (!serviceDid) {
+ return {
+ ok: false,
+ error: {
+ code: "InvalidResponse",
+ message: `Feed generator record contains invalid DID: ${validated.value.did}`,
+ },
+ };
+ }
+ const serviceDidDoc = await getDidDoc(serviceDid);
+ const service = serviceDidDoc.service?.find(
+ (s) => s.type === "FrontpageFeedGenerator",
+ );
+ if (!service || typeof service.serviceEndpoint !== "string") {
+ return {
+ ok: false,
+ error: {
+ code: "InvalidResponse",
+ message: `No FrontpageFeedGenerator service found in DID document for ${validated.value.did}`,
+ },
+ };
+ }
+
+ const serviceUrl = safeParseUrl(service.serviceEndpoint);
+ if (!serviceUrl) {
+ return {
+ ok: false,
+ error: {
+ code: "InvalidResponse",
+ message: `Invalid serviceEndpoint URL in DID document for ${validated.value.did}: ${service.serviceEndpoint}`,
+ },
+ };
+ }
+
+ if (serviceUrl.protocol !== "https:") {
+ return {
+ ok: false,
+ error: {
+ code: "InvalidResponse",
+ message: `Service endpoint must use HTTPS in DID document for ${validated.value.did}: ${service.serviceEndpoint}`,
+ },
+ };
+ }
+
+ try {
+ assertPublicHostname(serviceUrl.hostname);
+ } catch (_) {
+ return {
+ ok: false,
+ error: {
+ code: "InvalidResponse",
+ message: `Service endpoint hostname is not allowed in DID document for ${validated.value.did}: ${serviceUrl.hostname}`,
+ },
+ };
+ }
-async function resolveServiceEndpoint(did: DID): Promise {
- const didDoc = await getDidDoc(did);
- const service = didDoc.service?.find(
- (serviceEntry) =>
- serviceEntry.type === "FrontpageFeedGenerator" ||
- serviceEntry.type === "BskyFeedGenerator",
- );
-
- invariant(
- service && typeof service.serviceEndpoint === "string",
- `No feed generator service found in DID document for ${did}`,
- );
-
- const url = new URL(service.serviceEndpoint);
- invariant(
- url.protocol === "https:",
- "Feed generator endpoint must use HTTPS",
- );
-
- assertPublicHostname(url.hostname);
+ return {
+ ok: true,
+ data: {
+ serviceDid,
+ service: service.serviceEndpoint,
+ displayName: validated.value.displayName,
+ description: validated.value.description,
+ },
+ };
+ },
+);
- return service.serviceEndpoint;
+function safeParseUrl(input: string): URL | null {
+ try {
+ return new URL(input);
+ } catch {
+ return null;
+ }
}
diff --git a/packages/frontpage/lib/feed-action.tsx b/packages/frontpage/lib/feed-action.tsx
index 9b5f30dd..d200d026 100644
--- a/packages/frontpage/lib/feed-action.tsx
+++ b/packages/frontpage/lib/feed-action.tsx
@@ -1,20 +1,12 @@
import { getFeed } from "@/lib/data/feed-resolver";
import { PostCard } from "@/app/(app)/_components/post-card";
-import { FEED_URIS } from "./feed-constants";
import { AtUri, type AtUriString } from "@atproto/syntax";
-const ALLOWED_FEED_URIS = new Set(
- Object.values(FEED_URIS).map((uri) => uri.toString()),
-);
-
export async function getMoreFeedPostsAction(
feedUriStr: AtUriString,
cursor: string | null,
) {
"use server";
- if (!ALLOWED_FEED_URIS.has(feedUriStr)) {
- throw new Error("Unknown feed");
- }
const feedUri = new AtUri(feedUriStr);
@@ -23,7 +15,7 @@ export async function getMoreFeedPostsAction(
throw new Error(result.error.message);
}
- const { posts, cursor: nextCursor } = result;
+ const { posts, cursor: nextCursor } = result.data;
return {
content: (