chore: mvp of comments f4cbdd79
Steve · 2026-01-09 19:51 8 file(s) · +933 −1
packages/client/src/components/post/GuestReply.tsx (added) +209 −0
1 +
import { useState, useEffect } from "react";
2 +
3 +
const API_URL = import.meta.env.PUBLIC_API_URL || "https://api.stevedylan.dev";
4 +
5 +
interface GuestAuthState {
6 +
	authenticated: boolean;
7 +
	did?: string;
8 +
	handle?: string;
9 +
	isGuest?: boolean;
10 +
	loading: boolean;
11 +
}
12 +
13 +
interface GuestReplyProps {
14 +
	atUri: string;
15 +
	postTitle: string;
16 +
	onReplyPosted?: () => void;
17 +
}
18 +
19 +
export function GuestReply({
20 +
	atUri,
21 +
	postTitle,
22 +
	onReplyPosted,
23 +
}: GuestReplyProps) {
24 +
	const [authState, setAuthState] = useState<GuestAuthState>({
25 +
		authenticated: false,
26 +
		loading: true,
27 +
	});
28 +
	const [replyContent, setReplyContent] = useState("");
29 +
	const [isSubmitting, setIsSubmitting] = useState(false);
30 +
	const [error, setError] = useState<string | null>(null);
31 +
	const [success, setSuccess] = useState(false);
32 +
33 +
	useEffect(() => {
34 +
		checkAuthStatus();
35 +
	}, []);
36 +
37 +
	const checkAuthStatus = async () => {
38 +
		try {
39 +
			const response = await fetch(`${API_URL}/guest-auth/status`, {
40 +
				credentials: "include",
41 +
			});
42 +
			const data = await response.json();
43 +
			setAuthState({
44 +
				authenticated: data.authenticated,
45 +
				did: data.did,
46 +
				handle: data.handle,
47 +
				isGuest: data.isGuest,
48 +
				loading: false,
49 +
			});
50 +
		} catch (error) {
51 +
			console.error("Failed to check auth status:", error);
52 +
			setAuthState({ authenticated: false, loading: false });
53 +
		}
54 +
	};
55 +
56 +
	const handleLogin = () => {
57 +
		// Pass current URL as returnTo parameter
58 +
		const currentPath = window.location.pathname;
59 +
		window.location.href = `${API_URL}/guest-auth/login?returnTo=${encodeURIComponent(currentPath)}`;
60 +
	};
61 +
62 +
	const handleLogout = async () => {
63 +
		try {
64 +
			await fetch(`${API_URL}/guest-auth/logout`, {
65 +
				method: "POST",
66 +
				credentials: "include",
67 +
			});
68 +
			setAuthState({ authenticated: false, loading: false });
69 +
			setReplyContent("");
70 +
		} catch (error) {
71 +
			console.error("Logout failed:", error);
72 +
		}
73 +
	};
74 +
75 +
	const handleReply = async (e: React.FormEvent) => {
76 +
		e.preventDefault();
77 +
78 +
		if (!replyContent.trim()) {
79 +
			setError("Reply content is required");
80 +
			return;
81 +
		}
82 +
83 +
		setIsSubmitting(true);
84 +
		setError(null);
85 +
		setSuccess(false);
86 +
87 +
		try {
88 +
			const response = await fetch(`${API_URL}/now/reply`, {
89 +
				method: "POST",
90 +
				credentials: "include",
91 +
				headers: {
92 +
					"Content-Type": "application/json",
93 +
				},
94 +
				body: JSON.stringify({
95 +
					parentUri: atUri,
96 +
					content: replyContent.trim(),
97 +
				}),
98 +
			});
99 +
100 +
			const data = await response.json();
101 +
102 +
			if (!response.ok) {
103 +
				throw new Error(data.error || "Failed to post reply");
104 +
			}
105 +
106 +
			setReplyContent("");
107 +
			setSuccess(true);
108 +
			setTimeout(() => setSuccess(false), 5000);
109 +
110 +
			// Notify parent to refresh replies list
111 +
			onReplyPosted?.();
112 +
		} catch (err) {
113 +
			setError(err instanceof Error ? err.message : "Failed to post reply");
114 +
		} finally {
115 +
			setIsSubmitting(false);
116 +
		}
117 +
	};
118 +
119 +
	const emailSubject = encodeURIComponent(`Re: ${postTitle}`);
120 +
	const mailtoLink = `mailto:contact@stevedylan.dev?subject=${emailSubject}`;
121 +
122 +
	if (authState.loading) {
123 +
		return (
124 +
			<div className="mt-8 p-6 border border-gray-700 rounded-lg">
125 +
				<p className="text-sm text-gray-400">Loading...</p>
126 +
			</div>
127 +
		);
128 +
	}
129 +
130 +
	return (
131 +
		<div className="mt-8 space-y-4">
132 +
			<h2 className="text-xl font-bold">Reply</h2>
133 +
134 +
			{!authState.authenticated ? (
135 +
				<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-4 py-2 border border-white hover:bg-white hover:text-black transition-colors text-sm"
143 +
						>
144 +
							Sign in with ATProto
145 +
						</button>
146 +
						<a
147 +
							href={mailtoLink}
148 +
							className="px-4 py-2 border border-white hover:bg-white hover:text-black transition-colors text-sm inline-block"
149 +
						>
150 +
							Reply via Email
151 +
						</a>
152 +
					</div>
153 +
				</div>
154 +
			) : (
155 +
				<div className="space-y-4">
156 +
					<div className="flex items-center justify-between">
157 +
						<p className="text-sm text-gray-400">
158 +
							Signed in as {authState.handle || authState.did}
159 +
						</p>
160 +
						<button
161 +
							onClick={handleLogout}
162 +
							className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
163 +
						>
164 +
							Sign out
165 +
						</button>
166 +
					</div>
167 +
168 +
					<form onSubmit={handleReply} className="space-y-3">
169 +
						<textarea
170 +
							value={replyContent}
171 +
							onChange={(e) => setReplyContent(e.target.value)}
172 +
							placeholder="Write your reply..."
173 +
							rows={4}
174 +
							className="w-full bg-transparent p-3 border border-white text-white resize-none"
175 +
							disabled={isSubmitting}
176 +
						/>
177 +
178 +
						<div className="flex items-center justify-between">
179 +
							<div className="flex items-center gap-3">
180 +
								{error && <span className="text-sm text-red-500">{error}</span>}
181 +
								{success && (
182 +
									<span className="text-sm text-green-500">
183 +
										Reply posted successfully!
184 +
									</span>
185 +
								)}
186 +
							</div>
187 +
188 +
							<div className="flex gap-3">
189 +
								<a
190 +
									href={mailtoLink}
191 +
									className="text-sm text-gray-500 hover:text-gray-300 transition-colors"
192 +
								>
193 +
									or email
194 +
								</a>
195 +
								<button
196 +
									type="submit"
197 +
									disabled={isSubmitting || !replyContent.trim()}
198 +
									className="px-4 py-2 border border-white hover:bg-white hover:text-black disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
199 +
								>
200 +
									{isSubmitting ? "Posting..." : "Post Reply"}
201 +
								</button>
202 +
							</div>
203 +
						</div>
204 +
					</form>
205 +
				</div>
206 +
			)}
207 +
		</div>
208 +
	);
209 +
}
packages/client/src/components/post/ReplyContainer.tsx (added) +28 −0
1 +
import { useState } from "react";
2 +
import { ReplyList } from "./ReplyList";
3 +
import { GuestReply } from "./GuestReply";
4 +
5 +
interface ReplyContainerProps {
6 +
	atUri: string;
7 +
	postTitle: string;
8 +
}
9 +
10 +
export function ReplyContainer({ atUri, postTitle }: ReplyContainerProps) {
11 +
	const [refreshKey, setRefreshKey] = useState(0);
12 +
13 +
	const handleReplyPosted = () => {
14 +
		// Increment key to force ReplyList to re-fetch
15 +
		setRefreshKey((prev) => prev + 1);
16 +
	};
17 +
18 +
	return (
19 +
		<>
20 +
			<ReplyList key={refreshKey} atUri={atUri} />
21 +
			<GuestReply
22 +
				atUri={atUri}
23 +
				postTitle={postTitle}
24 +
				onReplyPosted={handleReplyPosted}
25 +
			/>
26 +
		</>
27 +
	);
28 +
}
packages/client/src/components/post/ReplyList.tsx (added) +174 −0
1 +
import { useState, useEffect } from "react";
2 +
3 +
const API_URL = import.meta.env.PUBLIC_API_URL || "https://api.stevedylan.dev";
4 +
5 +
interface Author {
6 +
	did: string;
7 +
	handle: string;
8 +
	displayName?: string;
9 +
	avatar?: string;
10 +
}
11 +
12 +
interface Reply {
13 +
	uri: string;
14 +
	cid: string;
15 +
	author: Author;
16 +
	record: {
17 +
		text: string;
18 +
		createdAt: string;
19 +
	};
20 +
	indexedAt: string;
21 +
	replyCount: number;
22 +
	likeCount: number;
23 +
}
24 +
25 +
interface ReplyListProps {
26 +
	atUri: string;
27 +
}
28 +
29 +
export function ReplyList({ atUri }: ReplyListProps) {
30 +
	const [replies, setReplies] = useState<Reply[]>([]);
31 +
	const [loading, setLoading] = useState(true);
32 +
	const [error, setError] = useState<string | null>(null);
33 +
34 +
	useEffect(() => {
35 +
		fetchReplies();
36 +
	}, [atUri]);
37 +
38 +
	const fetchReplies = async () => {
39 +
		try {
40 +
			setLoading(true);
41 +
			setError(null);
42 +
43 +
			const encodedUri = encodeURIComponent(atUri);
44 +
			const response = await fetch(`${API_URL}/now/replies/${encodedUri}`);
45 +
46 +
			if (!response.ok) {
47 +
				throw new Error("Failed to fetch replies");
48 +
			}
49 +
50 +
			const data = await response.json();
51 +
			setReplies(data.replies || []);
52 +
		} catch (err) {
53 +
			console.error("Error fetching replies:", err);
54 +
			setError(err instanceof Error ? err.message : "Failed to load replies");
55 +
		} finally {
56 +
			setLoading(false);
57 +
		}
58 +
	};
59 +
60 +
	const formatDate = (dateString: string) => {
61 +
		const date = new Date(dateString);
62 +
		const now = new Date();
63 +
		const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
64 +
65 +
		if (diffInSeconds < 60) {
66 +
			return `${diffInSeconds}s ago`;
67 +
		} else if (diffInSeconds < 3600) {
68 +
			return `${Math.floor(diffInSeconds / 60)}m ago`;
69 +
		} else if (diffInSeconds < 86400) {
70 +
			return `${Math.floor(diffInSeconds / 3600)}h ago`;
71 +
		} else if (diffInSeconds < 604800) {
72 +
			return `${Math.floor(diffInSeconds / 86400)}d ago`;
73 +
		} else {
74 +
			return date.toLocaleDateString();
75 +
		}
76 +
	};
77 +
78 +
	if (loading) {
79 +
		return (
80 +
			<div className="mt-8">
81 +
				<h3 className="text-lg font-bold mb-4">Replies</h3>
82 +
				<p className="text-sm text-gray-400">Loading replies...</p>
83 +
			</div>
84 +
		);
85 +
	}
86 +
87 +
	if (error) {
88 +
		return (
89 +
			<div className="mt-8">
90 +
				<h3 className="text-lg font-bold mb-4">Replies</h3>
91 +
				<p className="text-sm text-red-500">{error}</p>
92 +
			</div>
93 +
		);
94 +
	}
95 +
96 +
	if (replies.length === 0) {
97 +
		return (
98 +
			<div className="mt-8">
99 +
				<h3 className="text-lg font-bold mb-4">Replies</h3>
100 +
				<p className="text-sm text-gray-400">
101 +
					No replies yet. Be the first to reply!
102 +
				</p>
103 +
			</div>
104 +
		);
105 +
	}
106 +
107 +
	return (
108 +
		<div className="mt-8">
109 +
			<h3 className="text-lg font-bold mb-4">Replies ({replies.length})</h3>
110 +
			<div className="space-y-4">
111 +
				{replies.map((reply) => (
112 +
					<div
113 +
						key={reply.uri}
114 +
						className="border border-gray-700 rounded-lg p-4 hover:border-gray-600 transition-colors"
115 +
					>
116 +
						<div className="flex items-start gap-3">
117 +
							{reply.author.avatar ? (
118 +
								<img
119 +
									src={reply.author.avatar}
120 +
									alt={reply.author.handle}
121 +
									className="w-10 h-10 rounded-full"
122 +
								/>
123 +
							) : (
124 +
								<div className="w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center">
125 +
									<span className="text-gray-400 text-sm">
126 +
										{reply.author.handle.charAt(0).toUpperCase()}
127 +
									</span>
128 +
								</div>
129 +
							)}
130 +
131 +
							<div className="flex-1 min-w-0">
132 +
								<div className="flex items-center gap-2 flex-wrap">
133 +
									<span className="font-semibold text-sm">
134 +
										{reply.author.displayName || reply.author.handle}
135 +
									</span>
136 +
									<a
137 +
										href={`https://bsky.app/profile/${reply.author.handle}`}
138 +
										target="_blank"
139 +
										rel="noopener noreferrer"
140 +
										className="text-xs text-gray-400 hover:text-gray-300"
141 +
									>
142 +
										@{reply.author.handle}
143 +
									</a>
144 +
									<span className="text-xs text-gray-500">
145 +
										{formatDate(reply.record.createdAt)}
146 +
									</span>
147 +
								</div>
148 +
149 +
								<p className="mt-2 text-sm whitespace-pre-wrap break-words">
150 +
									{reply.record.text}
151 +
								</p>
152 +
153 +
								<div className="mt-3 flex items-center gap-4 text-xs text-gray-500">
154 +
									{reply.replyCount > 0 && (
155 +
										<span>{reply.replyCount} replies</span>
156 +
									)}
157 +
									{reply.likeCount > 0 && <span>{reply.likeCount} likes</span>}
158 +
									<a
159 +
										href={`https://bsky.app/profile/${reply.author.handle}/post/${reply.uri.split("/").pop()}`}
160 +
										target="_blank"
161 +
										rel="noopener noreferrer"
162 +
										className="hover:text-gray-300"
163 +
									>
164 +
										View on Bluesky
165 +
									</a>
166 +
								</div>
167 +
							</div>
168 +
						</div>
169 +
					</div>
170 +
				))}
171 +
			</div>
172 +
		</div>
173 +
	);
174 +
}
packages/client/src/pages/now/[slug].astro +2 −0
1 1
---
2 2
import PageLayout from "@/layouts/Base";
3 3
import MarkdownIt from "markdown-it";
4 +
import { ReplyContainer } from "@/components/post/ReplyContainer";
4 5
5 6
export const prerender = false;
6 7
145 146
						Share
146 147
					</button>
147 148
				</div>
149 +
				<ReplyContainer client:load atUri={atUri} postTitle={title} />
148 150
			</>
149 151
		)}
150 152
	</article>
packages/server/src/index.ts +2 −1
1 1
import { Hono } from "hono";
2 2
import { cors } from "hono/cors";
3 -
import { home, now, auth } from "./routes";
3 +
import { home, now, auth, guestAuth } from "./routes";
4 4
5 5
interface Env {
6 6
	SESSIONS: KVNamespace;
32 32
app.route("/", home);
33 33
app.route("/now", now);
34 34
app.route("/auth", auth);
35 +
app.route("/guest-auth", guestAuth);
35 36
36 37
export default app;
packages/server/src/routes/guest-auth.ts (added) +284 −0
1 +
import { Hono } from "hono";
2 +
import { generateDPoPKeyPair } from "../lib/dpop";
3 +
import {
4 +
	fetchOAuthMetadata,
5 +
	generatePKCE,
6 +
	generateState,
7 +
	sendPAR,
8 +
	buildAuthorizationUrl,
9 +
	exchangeCodeForTokens,
10 +
	refreshAccessToken,
11 +
} from "../lib/oauth";
12 +
import {
13 +
	storeAuthState,
14 +
	getAndDeleteAuthState,
15 +
	createSession,
16 +
	getSession,
17 +
	deleteSession,
18 +
	getSessionIdFromCookie,
19 +
	setSessionCookie,
20 +
	clearSessionCookie,
21 +
	isTokenExpired,
22 +
	updateSession,
23 +
} from "../lib/session";
24 +
25 +
interface Env {
26 +
	SESSIONS: KVNamespace;
27 +
	ALLOWED_DID: string;
28 +
	PDS_URL: string;
29 +
	CLIENT_URL: string;
30 +
	API_URL: string;
31 +
}
32 +
33 +
const guestAuth = new Hono<{ Bindings: Env }>();
34 +
35 +
// OAuth client metadata endpoint for guests
36 +
guestAuth.get("/client-metadata.json", (c) => {
37 +
	const clientId = `${c.env.API_URL}/guest-auth/client-metadata.json`;
38 +
	const redirectUri = `${c.env.API_URL}/guest-auth/callback`;
39 +
40 +
	return c.json({
41 +
		client_id: clientId,
42 +
		client_name: "Steve Dylan's Blog (Guest)",
43 +
		client_uri: c.env.API_URL,
44 +
		redirect_uris: [redirectUri],
45 +
		grant_types: ["authorization_code", "refresh_token"],
46 +
		response_types: ["code"],
47 +
		scope: "atproto transition:generic",
48 +
		token_endpoint_auth_method: "none",
49 +
		application_type: "web",
50 +
		dpop_bound_access_tokens: true,
51 +
	});
52 +
});
53 +
54 +
// Start OAuth login flow for guests
55 +
guestAuth.get("/login", async (c) => {
56 +
	try {
57 +
		const clientId = `${c.env.API_URL}/guest-auth/client-metadata.json`;
58 +
		const redirectUri = `${c.env.API_URL}/guest-auth/callback`;
59 +
60 +
		// Get optional return URL from query params
61 +
		const returnTo = c.req.query("returnTo") || "/now";
62 +
63 +
		// Fetch OAuth metadata from PDS
64 +
		const metadata = await fetchOAuthMetadata(c.env.PDS_URL);
65 +
66 +
		// Generate PKCE and state
67 +
		const pkce = await generatePKCE();
68 +
		const state = generateState();
69 +
70 +
		// Generate DPoP keypair
71 +
		const dpopKeyPair = await generateDPoPKeyPair();
72 +
73 +
		// Send PAR request
74 +
		const { parResponse, dpopNonce } = await sendPAR(
75 +
			metadata,
76 +
			clientId,
77 +
			redirectUri,
78 +
			state,
79 +
			pkce,
80 +
			dpopKeyPair,
81 +
			"atproto transition:generic",
82 +
		);
83 +
84 +
		// Store auth state in KV with returnTo URL
85 +
		await storeAuthState(
86 +
			c.env.SESSIONS,
87 +
			state,
88 +
			pkce.codeVerifier,
89 +
			dpopKeyPair,
90 +
			dpopNonce,
91 +
		);
92 +
93 +
		// Store returnTo separately to retrieve after callback
94 +
		await c.env.SESSIONS.put(
95 +
			`guest_return:${state}`,
96 +
			returnTo,
97 +
			{ expirationTtl: 600 }, // 10 minutes
98 +
		);
99 +
100 +
		// Build authorization URL and redirect
101 +
		const authUrl = buildAuthorizationUrl(
102 +
			metadata,
103 +
			parResponse.request_uri,
104 +
			clientId,
105 +
		);
106 +
		return c.redirect(authUrl);
107 +
	} catch (error) {
108 +
		console.error("Guest login error:", error);
109 +
		return c.redirect(`${c.env.CLIENT_URL}/now?error=login_failed`);
110 +
	}
111 +
});
112 +
113 +
// OAuth callback handler for guests
114 +
guestAuth.get("/callback", async (c) => {
115 +
	try {
116 +
		const code = c.req.query("code");
117 +
		const state = c.req.query("state");
118 +
		const error = c.req.query("error");
119 +
		const errorDescription = c.req.query("error_description");
120 +
121 +
		// Handle OAuth errors
122 +
		if (error) {
123 +
			console.error("OAuth error:", error, errorDescription);
124 +
			return c.redirect(
125 +
				`${c.env.CLIENT_URL}/now?error=${encodeURIComponent(error)}`,
126 +
			);
127 +
		}
128 +
129 +
		if (!code || !state) {
130 +
			return c.redirect(`${c.env.CLIENT_URL}/now?error=missing_params`);
131 +
		}
132 +
133 +
		// Retrieve and validate auth state
134 +
		const authState = await getAndDeleteAuthState(c.env.SESSIONS, state);
135 +
		if (!authState) {
136 +
			return c.redirect(`${c.env.CLIENT_URL}/now?error=invalid_state`);
137 +
		}
138 +
139 +
		// Get return URL
140 +
		const returnTo =
141 +
			(await c.env.SESSIONS.get(`guest_return:${state}`)) || "/now";
142 +
		await c.env.SESSIONS.delete(`guest_return:${state}`);
143 +
144 +
		const clientId = `${c.env.API_URL}/guest-auth/client-metadata.json`;
145 +
		const redirectUri = `${c.env.API_URL}/guest-auth/callback`;
146 +
147 +
		// Fetch OAuth metadata
148 +
		const metadata = await fetchOAuthMetadata(c.env.PDS_URL);
149 +
150 +
		// Exchange code for tokens
151 +
		const { tokenResponse, dpopNonce } = await exchangeCodeForTokens(
152 +
			metadata,
153 +
			code,
154 +
			authState.codeVerifier,
155 +
			clientId,
156 +
			redirectUri,
157 +
			authState.dpopKeyPair,
158 +
			authState.dpopNonce,
159 +
		);
160 +
161 +
		// For guests, allow any ATProto account (no DID check)
162 +
		// Create session with a "guest_" prefix to differentiate from admin sessions
163 +
		const sessionId = await createSession(
164 +
			c.env.SESSIONS,
165 +
			tokenResponse.access_token,
166 +
			tokenResponse.refresh_token || "",
167 +
			authState.dpopKeyPair,
168 +
			dpopNonce,
169 +
			tokenResponse.sub,
170 +
			tokenResponse.expires_in,
171 +
		);
172 +
173 +
		// Prefix session ID to mark as guest
174 +
		const guestSessionId = `guest_${sessionId}`;
175 +
176 +
		// Store the original session ID mapping
177 +
		await c.env.SESSIONS.put(
178 +
			`guest_session:${guestSessionId}`,
179 +
			sessionId,
180 +
			{ expirationTtl: 60 * 60 * 24 * 14 }, // 14 days
181 +
		);
182 +
183 +
		// Set session cookie and redirect to return URL
184 +
		setSessionCookie(c, guestSessionId, c.env.CLIENT_URL);
185 +
		return c.redirect(`${c.env.CLIENT_URL}${returnTo}`);
186 +
	} catch (error) {
187 +
		console.error("Guest callback error:", error);
188 +
		return c.redirect(`${c.env.CLIENT_URL}/now?error=callback_failed`);
189 +
	}
190 +
});
191 +
192 +
// Logout endpoint for guests
193 +
guestAuth.post("/logout", async (c) => {
194 +
	const sessionId = getSessionIdFromCookie(c);
195 +
196 +
	if (sessionId?.startsWith("guest_")) {
197 +
		// Get original session ID
198 +
		const originalSessionId = await c.env.SESSIONS.get(
199 +
			`guest_session:${sessionId}`,
200 +
		);
201 +
		if (originalSessionId) {
202 +
			await deleteSession(c.env.SESSIONS, originalSessionId);
203 +
			await c.env.SESSIONS.delete(`guest_session:${sessionId}`);
204 +
		}
205 +
	}
206 +
207 +
	clearSessionCookie(c, c.env.CLIENT_URL);
208 +
	return c.json({ success: true });
209 +
});
210 +
211 +
// Check auth status for guests
212 +
guestAuth.get("/status", async (c) => {
213 +
	const sessionId = getSessionIdFromCookie(c);
214 +
215 +
	if (!sessionId || !sessionId.startsWith("guest_")) {
216 +
		return c.json({ authenticated: false });
217 +
	}
218 +
219 +
	// Get original session ID
220 +
	const originalSessionId = await c.env.SESSIONS.get(
221 +
		`guest_session:${sessionId}`,
222 +
	);
223 +
	if (!originalSessionId) {
224 +
		clearSessionCookie(c, c.env.CLIENT_URL);
225 +
		return c.json({ authenticated: false });
226 +
	}
227 +
228 +
	const sessionData = await getSession(c.env.SESSIONS, originalSessionId);
229 +
	if (!sessionData) {
230 +
		clearSessionCookie(c, c.env.CLIENT_URL);
231 +
		await c.env.SESSIONS.delete(`guest_session:${sessionId}`);
232 +
		return c.json({ authenticated: false });
233 +
	}
234 +
235 +
	const { session, dpopKeyPair } = sessionData;
236 +
237 +
	// Check if token needs refresh
238 +
	if (isTokenExpired(session.expiresAt) && session.refreshToken) {
239 +
		try {
240 +
			const metadata = await fetchOAuthMetadata(c.env.PDS_URL);
241 +
			const clientId = `${c.env.API_URL}/guest-auth/client-metadata.json`;
242 +
243 +
			const { tokenResponse, dpopNonce } = await refreshAccessToken(
244 +
				metadata,
245 +
				session.refreshToken,
246 +
				clientId,
247 +
				dpopKeyPair,
248 +
				session.dpopNonce,
249 +
			);
250 +
251 +
			// Update session with new tokens
252 +
			await updateSession(
253 +
				c.env.SESSIONS,
254 +
				originalSessionId,
255 +
				tokenResponse.access_token,
256 +
				tokenResponse.refresh_token || session.refreshToken,
257 +
				dpopNonce,
258 +
				tokenResponse.expires_in,
259 +
			);
260 +
261 +
			return c.json({
262 +
				authenticated: true,
263 +
				did: session.did,
264 +
				handle: session.handle,
265 +
				isGuest: true,
266 +
			});
267 +
		} catch (error) {
268 +
			console.error("Token refresh failed:", error);
269 +
			await deleteSession(c.env.SESSIONS, originalSessionId);
270 +
			await c.env.SESSIONS.delete(`guest_session:${sessionId}`);
271 +
			clearSessionCookie(c, c.env.CLIENT_URL);
272 +
			return c.json({ authenticated: false });
273 +
		}
274 +
	}
275 +
276 +
	return c.json({
277 +
		authenticated: true,
278 +
		did: session.did,
279 +
		handle: session.handle,
280 +
		isGuest: true,
281 +
	});
282 +
});
283 +
284 +
export default guestAuth;
packages/server/src/routes/index.ts +1 −0
1 1
export { default as home } from "./home";
2 2
export { default as now } from "./now";
3 3
export { default as auth } from "./auth";
4 +
export { default as guestAuth } from "./guest-auth";
packages/server/src/routes/now.ts +233 −0
23 23
const DID = "did:plc:ia2zdnhjaokf5lazhxrmj6eu";
24 24
const PDS_URL = "https://polybius.social";
25 25
26 +
// Helper function to get session for both admin and guest users
27 +
async function getAnySession(c: any, sessionId: string) {
28 +
	if (sessionId.startsWith("guest_")) {
29 +
		// Guest session
30 +
		const originalSessionId = await c.env.SESSIONS.get(
31 +
			`guest_session:${sessionId}`,
32 +
		);
33 +
		if (!originalSessionId) return null;
34 +
		return await getSession(c.env.SESSIONS, originalSessionId);
35 +
	} else {
36 +
		// Admin session
37 +
		return await getSession(c.env.SESSIONS, sessionId);
38 +
	}
39 +
}
40 +
26 41
// Create a new post
27 42
now.post("/post", async (c) => {
28 43
	try {
297 312
		return c.text(errorFeed.rss2(), 200, {
298 313
			"Content-Type": "application/xml",
299 314
		});
315 +
	}
316 +
});
317 +
318 +
// Create a reply to a post (for guests)
319 +
now.post("/reply", async (c) => {
320 +
	try {
321 +
		// Get session from cookie
322 +
		const sessionId = getSessionIdFromCookie(c);
323 +
		if (!sessionId) {
324 +
			return c.json({ error: "Not authenticated" }, 401);
325 +
		}
326 +
327 +
		const sessionData = await getAnySession(c, sessionId);
328 +
		if (!sessionData) {
329 +
			return c.json({ error: "Session not found" }, 401);
330 +
		}
331 +
332 +
		let { session, dpopKeyPair } = sessionData;
333 +
334 +
		// Refresh token if expired
335 +
		if (isTokenExpired(session.expiresAt) && session.refreshToken) {
336 +
			const metadata = await fetchOAuthMetadata(c.env.PDS_URL);
337 +
			const clientId = sessionId.startsWith("guest_")
338 +
				? `${c.env.API_URL}/guest-auth/client-metadata.json`
339 +
				: `${c.env.API_URL}/auth/client-metadata.json`;
340 +
341 +
			const { tokenResponse, dpopNonce } = await refreshAccessToken(
342 +
				metadata,
343 +
				session.refreshToken,
344 +
				clientId,
345 +
				dpopKeyPair,
346 +
				session.dpopNonce,
347 +
			);
348 +
349 +
			// Get the actual session ID for update
350 +
			const actualSessionId = sessionId.startsWith("guest_")
351 +
				? (await c.env.SESSIONS.get(`guest_session:${sessionId}`)) || ""
352 +
				: sessionId;
353 +
354 +
			// Update session with new tokens
355 +
			await updateSession(
356 +
				c.env.SESSIONS,
357 +
				actualSessionId,
358 +
				tokenResponse.access_token,
359 +
				tokenResponse.refresh_token || session.refreshToken,
360 +
				dpopNonce,
361 +
				tokenResponse.expires_in,
362 +
			);
363 +
364 +
			// Update local session object
365 +
			session.accessToken = tokenResponse.access_token;
366 +
			session.dpopNonce = dpopNonce;
367 +
		}
368 +
369 +
		// Parse request body
370 +
		const body = await c.req.json<{
371 +
			parentUri: string;
372 +
			content: string;
373 +
		}>();
374 +
375 +
		if (!body.parentUri || body.parentUri.trim().length === 0) {
376 +
			return c.json({ error: "Parent URI is required" }, 400);
377 +
		}
378 +
379 +
		if (!body.content || body.content.trim().length === 0) {
380 +
			return c.json({ error: "Content is required" }, 400);
381 +
		}
382 +
383 +
		// Fetch the parent post to get its CID
384 +
		const getRecordUrl =
385 +
			`${c.env.PDS_URL}/xrpc/com.atproto.repo.getRecord?` +
386 +
			new URLSearchParams({
387 +
				repo: body.parentUri.split("/")[2], // Extract DID from URI
388 +
				collection: body.parentUri.split("/")[3], // Extract collection
389 +
				rkey: body.parentUri.split("/")[4], // Extract rkey
390 +
			});
391 +
392 +
		const parentResponse = await fetch(getRecordUrl);
393 +
		if (!parentResponse.ok) {
394 +
			console.error("Failed to fetch parent post");
395 +
			return c.json({ error: "Failed to fetch parent post" }, 400);
396 +
		}
397 +
398 +
		const parentData = await parentResponse.json();
399 +
		const parentCid = parentData.cid;
400 +
401 +
		// Create the reply record using app.bsky.feed.post lexicon
402 +
		const createRecordUrl = `${c.env.PDS_URL}/xrpc/com.atproto.repo.createRecord`;
403 +
404 +
		const replyRecord = {
405 +
			repo: session.did,
406 +
			collection: "app.bsky.feed.post",
407 +
			record: {
408 +
				$type: "app.bsky.feed.post",
409 +
				text: body.content.trim(),
410 +
				createdAt: new Date().toISOString(),
411 +
				reply: {
412 +
					root: {
413 +
						uri: body.parentUri,
414 +
						cid: parentCid,
415 +
					},
416 +
					parent: {
417 +
						uri: body.parentUri,
418 +
						cid: parentCid,
419 +
					},
420 +
				},
421 +
			},
422 +
		};
423 +
424 +
		// Make request with DPoP
425 +
		const makeRequest = async (nonce?: string): Promise<Response> => {
426 +
			const dpopProof = await createDPoPProof(dpopKeyPair, {
427 +
				method: "POST",
428 +
				url: createRecordUrl,
429 +
				nonce: nonce || session.dpopNonce,
430 +
				accessToken: session.accessToken,
431 +
			});
432 +
433 +
			return fetch(createRecordUrl, {
434 +
				method: "POST",
435 +
				headers: {
436 +
					"Content-Type": "application/json",
437 +
					Authorization: `DPoP ${session.accessToken}`,
438 +
					DPoP: dpopProof,
439 +
				},
440 +
				body: JSON.stringify(replyRecord),
441 +
			});
442 +
		};
443 +
444 +
		let response = await makeRequest();
445 +
446 +
		// Handle DPoP nonce requirement
447 +
		if (response.status === 401) {
448 +
			const newNonce = extractDPoPNonce(response);
449 +
			if (newNonce) {
450 +
				// Retry with new nonce
451 +
				response = await makeRequest(newNonce);
452 +
453 +
				// Get the actual session ID for update
454 +
				const actualSessionId = sessionId.startsWith("guest_")
455 +
					? (await c.env.SESSIONS.get(`guest_session:${sessionId}`)) || ""
456 +
					: sessionId;
457 +
458 +
				// Update session with new nonce
459 +
				await updateSession(
460 +
					c.env.SESSIONS,
461 +
					actualSessionId,
462 +
					session.accessToken,
463 +
					session.refreshToken,
464 +
					newNonce,
465 +
					Math.floor((session.expiresAt - Date.now()) / 1000),
466 +
				);
467 +
			}
468 +
		}
469 +
470 +
		if (!response.ok) {
471 +
			const errorData = await response.json();
472 +
			console.error("Failed to create reply:", errorData);
473 +
			return c.json(
474 +
				{ error: "Failed to create reply", details: errorData },
475 +
				response.status as 400 | 401 | 403 | 500,
476 +
			);
477 +
		}
478 +
479 +
		const result = (await response.json()) as { uri: string; cid: string };
480 +
		return c.json({ success: true, uri: result.uri, cid: result.cid });
481 +
	} catch (error) {
482 +
		console.error("Error creating reply:", error);
483 +
		return c.json({ error: "Internal server error" }, 500);
484 +
	}
485 +
});
486 +
487 +
// Get replies for a post
488 +
now.get("/replies/:uri", async (c) => {
489 +
	try {
490 +
		const encodedUri = c.req.param("uri");
491 +
		const uri = decodeURIComponent(encodedUri);
492 +
493 +
		// Use app.bsky.feed.getPostThread to fetch the post and its replies
494 +
		const threadUrl = `${PDS_URL}/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uri)}`;
495 +
496 +
		const response = await fetch(threadUrl);
497 +
498 +
		if (!response.ok) {
499 +
			console.error("Failed to fetch thread:", response.status);
500 +
			return c.json({ replies: [] });
501 +
		}
502 +
503 +
		const data = await response.json();
504 +
505 +
		// Extract replies from the thread
506 +
		const replies: any[] = [];
507 +
508 +
		if (data.thread?.replies && Array.isArray(data.thread.replies)) {
509 +
			for (const reply of data.thread.replies) {
510 +
				if (reply.post) {
511 +
					replies.push({
512 +
						uri: reply.post.uri,
513 +
						cid: reply.post.cid,
514 +
						author: {
515 +
							did: reply.post.author.did,
516 +
							handle: reply.post.author.handle,
517 +
							displayName: reply.post.author.displayName,
518 +
							avatar: reply.post.author.avatar,
519 +
						},
520 +
						record: reply.post.record,
521 +
						indexedAt: reply.post.indexedAt,
522 +
						replyCount: reply.post.replyCount || 0,
523 +
						likeCount: reply.post.likeCount || 0,
524 +
					});
525 +
				}
526 +
			}
527 +
		}
528 +
529 +
		return c.json({ replies });
530 +
	} catch (error) {
531 +
		console.error("Error fetching replies:", error);
532 +
		return c.json({ replies: [] });
300 533
	}
301 534
});
302 535