chore: refactored replies to work with tap e26c93e8
Steve · 2026-01-11 11:34 6 file(s) · +321 −55
packages/client/src/components/post/GuestReply.tsx +65 −20
26 26
		loading: true,
27 27
	});
28 28
	const [replyContent, setReplyContent] = useState("");
29 +
	const [handleInput, setHandleInput] = useState("");
30 +
	const [showHandleForm, setShowHandleForm] = useState(false);
29 31
	const [isSubmitting, setIsSubmitting] = useState(false);
30 32
	const [error, setError] = useState<string | null>(null);
31 33
	const [success, setSuccess] = useState(false);
53 55
		}
54 56
	};
55 57
56 -
	const handleLogin = () => {
57 -
		// Pass current URL as returnTo parameter
58 +
	const handleLogin = (e: React.FormEvent) => {
59 +
		e.preventDefault();
60 +
		if (!handleInput.trim()) {
61 +
			setError("Handle is required");
62 +
			return;
63 +
		}
64 +
		// Pass current URL as returnTo parameter and handle
58 65
		const currentPath = window.location.pathname;
59 -
		window.location.href = `${API_URL}/guest-auth/login?returnTo=${encodeURIComponent(currentPath)}`;
66 +
		const handle = handleInput.trim().replace(/^@/, ""); // Remove leading @ if present
67 +
		window.location.href = `${API_URL}/guest-auth/login?handle=${encodeURIComponent(handle)}&returnTo=${encodeURIComponent(currentPath)}`;
60 68
	};
61 69
62 70
	const handleLogout = async () => {
133 141
134 142
			{!authState.authenticated ? (
135 143
				<div className="space-y-4">
136 -
					<p className="text-sm text-gray-400">
137 -
						Sign in with your ATProto account to reply, or send an email.
138 -
					</p>
139 -
					<div className="flex gap-3 flex-wrap">
140 -
						<button
141 -
							onClick={handleLogin}
142 -
							className="px-2 py-0.5 border border-white hover:border-gray-400 hover:text-gray-400 transition-colors text-xs"
143 -
						>
144 -
							Sign in with ATProto
145 -
						</button>
146 -
						<a
147 -
							href={mailtoLink}
148 -
							className="px-4 py-0.5 border border-white hover:border-gray-400 hover:text-gray-400 transition-colors text-xs"
149 -
						>
150 -
							Reply via Email
151 -
						</a>
152 -
					</div>
144 +
					{!showHandleForm ? (
145 +
						<>
146 +
							{/*<p className="text-sm text-gray-400">
147 +
								Sign in with your ATProto account to reply, or send an email.
148 +
							</p>*/}
149 +
							<div className="flex gap-3 flex-wrap">
150 +
								<button
151 +
									onClick={() => setShowHandleForm(true)}
152 +
									className="px-2 py-0.5 border border-white hover:border-gray-400 hover:text-gray-400 transition-colors text-xs"
153 +
								>
154 +
									Sign in with ATProto
155 +
								</button>
156 +
								<a
157 +
									href={mailtoLink}
158 +
									className="px-2 py-0.5 border border-white hover:border-gray-400 hover:text-gray-400 transition-colors text-xs"
159 +
								>
160 +
									Reply via Email
161 +
								</a>
162 +
							</div>
163 +
						</>
164 +
					) : (
165 +
						<form onSubmit={handleLogin} className="space-y-3">
166 +
							<p className="text-sm text-gray-400">
167 +
								Enter your handle to sign in:
168 +
							</p>
169 +
							<div className="flex gap-2">
170 +
								<input
171 +
									type="text"
172 +
									value={handleInput}
173 +
									onChange={(e) => setHandleInput(e.target.value)}
174 +
									placeholder="user.bsky.social"
175 +
									className="flex-1 bg-transparent px-3 py-1 border border-white text-white text-sm"
176 +
								/>
177 +
								<button
178 +
									type="submit"
179 +
									className="px-3 py-1 border border-white hover:border-gray-400 hover:text-gray-400 transition-colors text-xs"
180 +
								>
181 +
									Sign in
182 +
								</button>
183 +
								<button
184 +
									type="button"
185 +
									onClick={() => {
186 +
										setShowHandleForm(false);
187 +
										setHandleInput("");
188 +
										setError(null);
189 +
									}}
190 +
									className="px-3 py-1 text-gray-500 hover:text-gray-300 transition-colors text-xs"
191 +
								>
192 +
									Cancel
193 +
								</button>
194 +
							</div>
195 +
							{error && <p className="text-sm text-red-500">{error}</p>}
196 +
						</form>
197 +
					)}
153 198
				</div>
154 199
			) : (
155 200
				<div className="space-y-4">
packages/client/src/components/post/ReplyList.tsx +20 −18
9 9
	avatar?: string;
10 10
}
11 11
12 +
interface CommentReference {
13 +
	createdAt: string;
14 +
	did: string;
15 +
	uri: string;
16 +
}
17 +
12 18
interface Reply {
13 19
	uri: string;
14 20
	cid: string;
15 21
	author: Author;
16 -
	record: {
17 -
		text: string;
18 -
		createdAt: string;
22 +
	root: {
23 +
		cid: string;
24 +
		uri: string;
19 25
	};
20 -
	indexedAt: string;
21 -
	replyCount: number;
22 -
	likeCount: number;
26 +
	parent: {
27 +
		cid: string;
28 +
		uri: string;
29 +
	};
30 +
	content: string;
31 +
	createdAt: string;
32 +
	$type: string;
23 33
}
24 34
25 35
interface ReplyListProps {
41 51
			setError(null);
42 52
43 53
			const encodedUri = encodeURIComponent(atUri);
44 -
			const response = await fetch(`${API_URL}/now/replies/${encodedUri}`);
54 +
			const response = await fetch(`${API_URL}/now/comments/${encodedUri}`);
45 55
46 56
			if (!response.ok) {
47 -
				throw new Error("Failed to fetch replies");
57 +
				throw new Error("Failed to fetch comments");
48 58
			}
49 59
50 60
			const data = await response.json();
144 154
										href={`https://pdsls.dev/${reply.uri}`}
145 155
										className="text-xs text-gray-400 hover:text-gray-300"
146 156
									>
147 -
										{formatDate(reply.record.createdAt)}
157 +
										{formatDate(reply.createdAt)}
148 158
									</a>
149 159
								</div>
150 160
151 161
								<p className="mt-2 text-sm whitespace-pre-wrap break-words">
152 -
									{reply.record.text}
162 +
									{reply.content}
153 163
								</p>
154 -
155 -
								{reply.replyCount > 0 && (
156 -
									<div className="mt-3 flex items-center gap-4 text-xs text-gray-500">
157 -
										{reply.replyCount > 0 && (
158 -
											<span>{reply.replyCount} replies</span>
159 -
										)}
160 -
									</div>
161 -
								)}
162 164
							</div>
163 165
						</div>
164 166
					</div>
packages/server/src/lib/oauth.ts +27 −1
36 36
	error_description?: string;
37 37
}
38 38
39 +
interface ProtectedResourceMetadata {
40 +
	resource: string;
41 +
	authorization_servers: string[];
42 +
}
43 +
39 44
/**
40 45
 * Fetch OAuth server metadata from PDS
46 +
 * First checks for protected resource metadata to find the authorization server,
47 +
 * then fetches OAuth metadata from there.
41 48
 */
42 49
export async function fetchOAuthMetadata(
43 50
	pdsUrl: string,
44 51
): Promise<OAuthServerMetadata> {
45 -
	const metadataUrl = `${pdsUrl}/.well-known/oauth-authorization-server`;
52 +
	// First, try to get the authorization server from protected resource metadata
53 +
	let authServerUrl = pdsUrl;
54 +
55 +
	try {
56 +
		const protectedResourceUrl = `${pdsUrl}/.well-known/oauth-protected-resource`;
57 +
		const prResponse = await fetch(protectedResourceUrl);
58 +
		if (prResponse.ok) {
59 +
			const prData = (await prResponse.json()) as ProtectedResourceMetadata;
60 +
			if (
61 +
				prData.authorization_servers &&
62 +
				prData.authorization_servers.length > 0
63 +
			) {
64 +
				authServerUrl = prData.authorization_servers[0];
65 +
			}
66 +
		}
67 +
	} catch {
68 +
		// If protected resource metadata fails, fall back to PDS URL
69 +
	}
70 +
71 +
	const metadataUrl = `${authServerUrl}/.well-known/oauth-authorization-server`;
46 72
	const response = await fetch(metadataUrl);
47 73
48 74
	if (!response.ok) {
packages/server/src/lib/session.ts +3 −0
10 10
	dpopNonce: string;
11 11
	did: string;
12 12
	handle?: string;
13 +
	pdsUrl?: string; // User's PDS URL (for guest sessions)
13 14
	expiresAt: number; // Unix timestamp
14 15
	createdAt: number;
15 16
}
106 107
	did: string,
107 108
	expiresIn: number,
108 109
	handle?: string,
110 +
	pdsUrl?: string,
109 111
): Promise<string> {
110 112
	const sessionId = generateSessionId();
111 113
	const exported = await exportDPoPKeyPair(dpopKeyPair);
118 120
		dpopNonce,
119 121
		did,
120 122
		handle,
123 +
		pdsUrl,
121 124
		expiresAt: Date.now() + expiresIn * 1000,
122 125
		createdAt: Date.now(),
123 126
	};
packages/server/src/routes/guest-auth.ts +106 −9
51 51
	});
52 52
});
53 53
54 +
// Resolve handle to PDS URL
55 +
async function resolveHandleToPDS(handle: string): Promise<string> {
56 +
	// First, resolve the handle to a DID
57 +
	let did: string;
58 +
59 +
	if (handle.startsWith("did:")) {
60 +
		did = handle;
61 +
	} else {
62 +
		// Try to resolve handle via Bluesky API
63 +
		const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
64 +
		const resolveResponse = await fetch(resolveUrl);
65 +
		if (!resolveResponse.ok) {
66 +
			throw new Error("Could not resolve handle");
67 +
		}
68 +
		const resolveData = (await resolveResponse.json()) as { did: string };
69 +
		did = resolveData.did;
70 +
	}
71 +
72 +
	// Now resolve the DID to get the PDS URL from the DID document
73 +
	let pdsUrl: string | undefined;
74 +
75 +
	if (did.startsWith("did:plc:")) {
76 +
		// Fetch DID document from plc.directory
77 +
		const didDocUrl = `https://plc.directory/${did}`;
78 +
		const didDocResponse = await fetch(didDocUrl);
79 +
		if (!didDocResponse.ok) {
80 +
			throw new Error("Could not fetch DID document");
81 +
		}
82 +
		const didDoc = (await didDocResponse.json()) as {
83 +
			service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
84 +
		};
85 +
86 +
		// Find the PDS service endpoint
87 +
		const pdsService = didDoc.service?.find(
88 +
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
89 +
		);
90 +
		pdsUrl = pdsService?.serviceEndpoint;
91 +
	} else if (did.startsWith("did:web:")) {
92 +
		// For did:web, fetch the DID document from the domain
93 +
		const domain = did.replace("did:web:", "");
94 +
		const didDocUrl = `https://${domain}/.well-known/did.json`;
95 +
		const didDocResponse = await fetch(didDocUrl);
96 +
		if (!didDocResponse.ok) {
97 +
			throw new Error("Could not fetch DID document");
98 +
		}
99 +
		const didDoc = (await didDocResponse.json()) as {
100 +
			service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
101 +
		};
102 +
103 +
		const pdsService = didDoc.service?.find(
104 +
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
105 +
		);
106 +
		pdsUrl = pdsService?.serviceEndpoint;
107 +
	}
108 +
109 +
	if (!pdsUrl) {
110 +
		throw new Error("Could not find PDS URL for user");
111 +
	}
112 +
113 +
	return pdsUrl;
114 +
}
115 +
54 116
// Start OAuth login flow for guests
55 117
guestAuth.get("/login", async (c) => {
56 118
	try {
57 119
		const clientId = `${c.env.API_URL}/guest-auth/client-metadata.json`;
58 120
		const redirectUri = `${c.env.API_URL}/guest-auth/callback`;
59 121
60 -
		// Get optional return URL from query params
122 +
		// Get handle from query params (required for guests)
123 +
		const handle = c.req.query("handle");
61 124
		const returnTo = c.req.query("returnTo") || "/now";
62 125
63 -
		// Fetch OAuth metadata from PDS
64 -
		const metadata = await fetchOAuthMetadata(c.env.PDS_URL);
126 +
		if (!handle) {
127 +
			return c.redirect(`${c.env.CLIENT_URL}/now?error=handle_required`);
128 +
		}
129 +
130 +
		// Resolve handle to their PDS
131 +
		let pdsUrl: string;
132 +
		try {
133 +
			pdsUrl = await resolveHandleToPDS(handle);
134 +
		} catch (err) {
135 +
			console.error("Failed to resolve handle:", err);
136 +
			return c.redirect(`${c.env.CLIENT_URL}/now?error=invalid_handle`);
137 +
		}
138 +
139 +
		// Fetch OAuth metadata from user's PDS
140 +
		const metadata = await fetchOAuthMetadata(pdsUrl);
65 141
66 142
		// Generate PKCE and state
67 143
		const pkce = await generatePKCE();
81 157
			"atproto repo:site.standard.document.comment?action=create",
82 158
		);
83 159
84 -
		// Store auth state in KV with returnTo URL
160 +
		// Store auth state in KV with returnTo URL and PDS URL
85 161
		await storeAuthState(
86 162
			c.env.SESSIONS,
87 163
			state,
90 166
			dpopNonce,
91 167
		);
92 168
93 -
		// Store returnTo separately to retrieve after callback
169 +
		// Store returnTo and pdsUrl separately to retrieve after callback
94 170
		await c.env.SESSIONS.put(
95 171
			`guest_return:${state}`,
96 172
			returnTo,
97 173
			{ expirationTtl: 600 }, // 10 minutes
98 174
		);
175 +
		await c.env.SESSIONS.put(
176 +
			`guest_pds:${state}`,
177 +
			pdsUrl,
178 +
			{ expirationTtl: 600 }, // 10 minutes
179 +
		);
99 180
100 181
		// Build authorization URL and redirect
101 182
		const authUrl = buildAuthorizationUrl(
136 217
			return c.redirect(`${c.env.CLIENT_URL}/now?error=invalid_state`);
137 218
		}
138 219
139 -
		// Get return URL
220 +
		// Get return URL and PDS URL
140 221
		const returnTo =
141 222
			(await c.env.SESSIONS.get(`guest_return:${state}`)) || "/now";
223 +
		const pdsUrl = await c.env.SESSIONS.get(`guest_pds:${state}`);
142 224
		await c.env.SESSIONS.delete(`guest_return:${state}`);
225 +
		await c.env.SESSIONS.delete(`guest_pds:${state}`);
226 +
227 +
		if (!pdsUrl) {
228 +
			return c.redirect(`${c.env.CLIENT_URL}/now?error=missing_pds`);
229 +
		}
143 230
144 231
		const clientId = `${c.env.API_URL}/guest-auth/client-metadata.json`;
145 232
		const redirectUri = `${c.env.API_URL}/guest-auth/callback`;
146 233
147 -
		// Fetch OAuth metadata
148 -
		const metadata = await fetchOAuthMetadata(c.env.PDS_URL);
234 +
		// Fetch OAuth metadata from user's PDS
235 +
		const metadata = await fetchOAuthMetadata(pdsUrl);
149 236
150 237
		// Exchange code for tokens
151 238
		const { tokenResponse, dpopNonce } = await exchangeCodeForTokens(
168 255
			dpopNonce,
169 256
			tokenResponse.sub,
170 257
			tokenResponse.expires_in,
258 +
			undefined, // handle
259 +
			pdsUrl, // user's PDS URL
171 260
		);
172 261
173 262
		// Prefix session ID to mark as guest
237 326
	// Check if token needs refresh
238 327
	if (isTokenExpired(session.expiresAt) && session.refreshToken) {
239 328
		try {
240 -
			const metadata = await fetchOAuthMetadata(c.env.PDS_URL);
329 +
			// Use the user's PDS URL stored in session
330 +
			if (!session.pdsUrl) {
331 +
				console.error("No PDS URL in session for token refresh");
332 +
				await deleteSession(c.env.SESSIONS, originalSessionId);
333 +
				await c.env.SESSIONS.delete(`guest_session:${sessionId}`);
334 +
				clearSessionCookie(c, c.env.CLIENT_URL);
335 +
				return c.json({ authenticated: false });
336 +
			}
337 +
			const metadata = await fetchOAuthMetadata(session.pdsUrl);
241 338
			const clientId = `${c.env.API_URL}/guest-auth/client-metadata.json`;
242 339
243 340
			const { tokenResponse, dpopNonce } = await refreshAccessToken(
packages/server/src/routes/now.ts +100 −7
331 331
332 332
		let { session, dpopKeyPair } = sessionData;
333 333
334 +
		// Determine which PDS to use (user's PDS for guests, env PDS for admin)
335 +
		const isGuest = sessionId.startsWith("guest_");
336 +
		const pdsUrl = isGuest && session.pdsUrl ? session.pdsUrl : c.env.PDS_URL;
337 +
334 338
		// Refresh token if expired
335 339
		if (isTokenExpired(session.expiresAt) && session.refreshToken) {
336 -
			const metadata = await fetchOAuthMetadata(c.env.PDS_URL);
337 -
			const clientId = sessionId.startsWith("guest_")
340 +
			const metadata = await fetchOAuthMetadata(pdsUrl);
341 +
			const clientId = isGuest
338 342
				? `${c.env.API_URL}/guest-auth/client-metadata.json`
339 343
				: `${c.env.API_URL}/auth/client-metadata.json`;
340 344
347 351
			);
348 352
349 353
			// Get the actual session ID for update
350 -
			const actualSessionId = sessionId.startsWith("guest_")
354 +
			const actualSessionId = isGuest
351 355
				? (await c.env.SESSIONS.get(`guest_session:${sessionId}`)) || ""
352 356
				: sessionId;
353 357
380 384
			return c.json({ error: "Content is required" }, 400);
381 385
		}
382 386
383 -
		// Fetch the parent post to get its CID
387 +
		// Fetch the parent post to get its CID (use owner's PDS since that's where the post lives)
384 388
		const getRecordUrl =
385 389
			`${c.env.PDS_URL}/xrpc/com.atproto.repo.getRecord?` +
386 390
			new URLSearchParams({
421 425
		}
422 426
423 427
		// Create the comment record using site.standard.document.comment lexicon
424 -
		const createRecordUrl = `${c.env.PDS_URL}/xrpc/com.atproto.repo.createRecord`;
428 +
		// Use the user's PDS URL since the record is stored in THEIR repo
429 +
		const createRecordUrl = `${pdsUrl}/xrpc/com.atproto.repo.createRecord`;
425 430
426 431
		const commentRecord = {
427 432
			repo: session.did,
477 482
				response = await makeRequest(newNonce);
478 483
479 484
				// Get the actual session ID for update
480 -
				const actualSessionId = sessionId.startsWith("guest_")
485 +
				const actualSessionId = isGuest
481 486
					? (await c.env.SESSIONS.get(`guest_session:${sessionId}`)) || ""
482 487
					: sessionId;
483 488
510 515
	}
511 516
});
512 517
513 -
// Get comments for a post
518 +
// Get comments for a post via TAP API
519 +
now.get("/comments/:uri", async (c) => {
520 +
	try {
521 +
		const encodedUri = c.req.param("uri");
522 +
		const uri = decodeURIComponent(encodedUri);
523 +
524 +
		// First, get the list of comment URIs from TAP API
525 +
		const tapUrl = `https://tap.stevedylan.dev/comments?document=${encodeURIComponent(uri)}`;
526 +
		const response = await fetch(tapUrl);
527 +
528 +
		if (!response.ok) {
529 +
			console.error("Failed to fetch comment list from TAP:", response.status);
530 +
			return c.json({ replies: [] });
531 +
		}
532 +
533 +
		interface CommentReference {
534 +
			createdAt: string;
535 +
			did: string;
536 +
			uri: string;
537 +
		}
538 +
539 +
		const commentRefs: CommentReference[] = await response.json();
540 +
541 +
		// Fetch each individual comment using ATProto getRecord
542 +
		const commentPromises = commentRefs.map(async (ref) => {
543 +
			try {
544 +
				// Parse the AT URI: at://did:plc:.../collection/rkey
545 +
				const parts = ref.uri.split("/");
546 +
				const did = parts[2];
547 +
				const collection = parts[3];
548 +
				const rkey = parts[4];
549 +
550 +
				// Resolve the DID to find the PDS endpoint
551 +
				const didDoc = await fetch(`https://plc.directory/${did}`).then((r) =>
552 +
					r.json(),
553 +
				);
554 +
555 +
				// Find the PDS service endpoint
556 +
				const pdsService = didDoc.service?.find(
557 +
					(s: any) => s.type === "AtprotoPersonalDataServer",
558 +
				);
559 +
560 +
				if (!pdsService?.serviceEndpoint) {
561 +
					console.error(`No PDS found for DID: ${did}`);
562 +
					return null;
563 +
				}
564 +
565 +
				const pdsUrl = pdsService.serviceEndpoint;
566 +
567 +
				// Fetch the record from the user's PDS
568 +
				const getRecordUrl =
569 +
					`${pdsUrl}/xrpc/com.atproto.repo.getRecord?` +
570 +
					new URLSearchParams({
571 +
						repo: did,
572 +
						collection: collection,
573 +
						rkey: rkey,
574 +
					});
575 +
576 +
				const recordResponse = await fetch(getRecordUrl);
577 +
				if (!recordResponse.ok) {
578 +
					console.error(
579 +
						`Failed to fetch comment from PDS ${pdsUrl}: ${ref.uri}`,
580 +
					);
581 +
					return null;
582 +
				}
583 +
584 +
				const data = await recordResponse.json();
585 +
				return {
586 +
					...data.value,
587 +
					uri: ref.uri,
588 +
					cid: data.cid,
589 +
				};
590 +
			} catch (err) {
591 +
				console.error(`Error fetching comment ${ref.uri}:`, err);
592 +
				return null;
593 +
			}
594 +
		});
595 +
596 +
		const comments = await Promise.all(commentPromises);
597 +
		const validComments = comments.filter((comment) => comment !== null);
598 +
599 +
		return c.json({ replies: validComments });
600 +
	} catch (error) {
601 +
		console.error("Error fetching comments:", error);
602 +
		return c.json({ replies: [] });
603 +
	}
604 +
});
605 +
606 +
// Get comments for a post (legacy endpoint)
514 607
now.get("/replies/:uri", async (c) => {
515 608
	try {
516 609
		const encodedUri = c.req.param("uri");