chore: cleaned up server a75bc683
Steve · 2026-01-11 12:32 9 file(s) · +156 −242
packages/client/src/components/now/NowUpdates.tsx +2 −4
1 1
import { useEffect, useState } from "react";
2 2
import MarkdownIt from "markdown-it";
3 +
import { OWNER_DID, PDS_URL } from "@/data/constants";
3 4
4 5
const md = new MarkdownIt({
5 6
	html: false,
6 7
	linkify: true,
7 8
	typographer: true,
8 9
});
9 -
10 -
const DID = "did:plc:ia2zdnhjaokf5lazhxrmj6eu";
11 -
const PDS_URL = "https://polybius.social";
12 10
13 11
interface Document {
14 12
	uri: string;
34 32
				const documentsData = await fetch(
35 33
					`${PDS_URL}/xrpc/com.atproto.repo.listRecords?` +
36 34
						new URLSearchParams({
37 -
							repo: DID,
35 +
							repo: OWNER_DID,
38 36
							collection: "site.standard.document",
39 37
							limit: "20",
40 38
						}),
packages/client/src/data/constants.ts +5 −1
1 +
// ATProto configuration
2 +
export const OWNER_DID = "did:plc:ia2zdnhjaokf5lazhxrmj6eu";
3 +
export const PDS_URL = "https://polybius.social";
4 +
1 5
export const MENU_LINKS = [
2 6
	{
3 7
		title: "Home",
37 41
export const SOCIAL_LINKS = {
38 42
	github: "https://github.com/stevedylandev",
39 43
	telegram: "https://telegram.me/stevedylandev",
40 -
	atproto: "https://pdsls.dev/at://did:plc:ia2zdnhjaokf5lazhxrmj6eu",
44 +
	atproto: `https://pdsls.dev/at://${OWNER_DID}`,
41 45
	linkedin: "https://www.linkedin.com/in/steve-simkins/",
42 46
	photos: "https://steve.photo",
43 47
	website: "/",
packages/client/src/pages/.well-known/site.standard.publication.ts +5 −8
1 +
import { OWNER_DID } from "@/data/constants";
2 +
1 3
export const prerender = true;
4 +
5 +
const PUBLICATION_RKEY = "self";
2 6
3 7
export async function GET() {
4 -
	// Your DID from PDSPost.tsx
5 -
	const DID = "did:plc:ia2zdnhjaokf5lazhxrmj6eu";
6 -
7 -
	// This should be the rkey of your actual site.standard.publication record
8 -
	// You'll need to create this record in your PDS first
9 -
	const PUBLICATION_RKEY = "self";
10 -
11 -
	const atUri = `at://${DID}/site.standard.publication/${PUBLICATION_RKEY}`;
8 +
	const atUri = `at://${OWNER_DID}/site.standard.publication/${PUBLICATION_RKEY}`;
12 9
13 10
	return new Response(atUri, {
14 11
		status: 200,
packages/client/src/pages/now/[slug].astro +4 −6
2 2
import PageLayout from "@/layouts/Base";
3 3
import MarkdownIt from "markdown-it";
4 4
import { ReplyContainer } from "@/components/now/ReplyContainer";
5 +
import { OWNER_DID, PDS_URL } from "@/data/constants";
5 6
6 7
export const prerender = false;
7 8
10 11
	linkify: true,
11 12
	typographer: true,
12 13
});
13 -
14 -
const DID = "did:plc:ia2zdnhjaokf5lazhxrmj6eu";
15 -
const PDS_URL = "https://polybius.social";
16 14
17 15
const { slug } = Astro.params;
18 16
35 33
	const listResponse = await fetch(
36 34
		`${PDS_URL}/xrpc/com.atproto.repo.listRecords?` +
37 35
			new URLSearchParams({
38 -
				repo: DID,
36 +
				repo: OWNER_DID,
39 37
				collection: "site.standard.document",
40 38
				limit: "100",
41 39
			}),
77 75
		const documentResponse = await fetch(
78 76
			`${PDS_URL}/xrpc/com.atproto.repo.getRecord?` +
79 77
				new URLSearchParams({
80 -
					repo: DID,
78 +
					repo: OWNER_DID,
81 79
					collection: "site.standard.document",
82 80
					rkey: slug,
83 81
				}),
89 87
			title = doc.title || "Post";
90 88
			description = doc.content?.markdown?.slice(0, 160) || description;
91 89
			publishedAt = new Date(doc.publishedAt).toLocaleDateString();
92 -
			atUri = `at://${DID}/site.standard.document/${slug}`;
90 +
			atUri = `at://${OWNER_DID}/site.standard.document/${slug}`;
93 91
94 92
			if (doc.content && doc.content.markdown) {
95 93
				contentHTML = md.render(doc.content.markdown);
packages/client/src/pages/now/rss.xml.ts +2 −4
1 1
import rss from "@astrojs/rss";
2 2
import sanitizeHtml from "sanitize-html";
3 3
import MarkdownIt from "markdown-it";
4 +
import { OWNER_DID, PDS_URL } from "@/data/constants";
4 5
5 6
export const prerender = false;
6 -
7 -
const DID = "did:plc:ia2zdnhjaokf5lazhxrmj6eu";
8 -
const PDS_URL = "https://polybius.social";
9 7
10 8
const md = new MarkdownIt({
11 9
	html: true,
40 38
		const response = await fetch(
41 39
			`${PDS_URL}/xrpc/com.atproto.repo.listRecords?` +
42 40
				new URLSearchParams({
43 -
					repo: DID,
41 +
					repo: OWNER_DID,
44 42
					collection: "site.standard.document",
45 43
					limit: "50",
46 44
				}),
packages/server/src/lib/oauth.ts +93 −1
1 1
import * as jose from "jose";
2 -
import { type DPoPKeyPair, createDPoPProof, extractDPoPNonce } from "./dpop";
2 +
import {
3 +
	type DPoPKeyPair,
4 +
	createDPoPProof,
5 +
	extractDPoPNonce,
6 +
	generateDPoPKeyPair,
7 +
} from "./dpop";
8 +
import { storeAuthState, getAndDeleteAuthState } from "./session";
3 9
4 10
export interface OAuthServerMetadata {
5 11
	issuer: string;
264 270
	return {
265 271
		tokenResponse,
266 272
		dpopNonce: newNonce || dpopNonce || "",
273 +
	};
274 +
}
275 +
276 +
export interface OAuthFlowConfig {
277 +
	pdsUrl: string;
278 +
	clientId: string;
279 +
	redirectUri: string;
280 +
	scope: string;
281 +
}
282 +
283 +
export interface InitiateOAuthResult {
284 +
	authUrl: string;
285 +
	state: string;
286 +
}
287 +
288 +
/**
289 +
 * Initiates an OAuth login flow - generates PKCE, DPoP keypair, sends PAR, and stores auth state
290 +
 */
291 +
export async function initiateOAuthFlow(
292 +
	kv: KVNamespace,
293 +
	config: OAuthFlowConfig,
294 +
): Promise<InitiateOAuthResult> {
295 +
	const metadata = await fetchOAuthMetadata(config.pdsUrl);
296 +
	const pkce = await generatePKCE();
297 +
	const state = generateState();
298 +
	const dpopKeyPair = await generateDPoPKeyPair();
299 +
300 +
	const { parResponse, dpopNonce } = await sendPAR(
301 +
		metadata,
302 +
		config.clientId,
303 +
		config.redirectUri,
304 +
		state,
305 +
		pkce,
306 +
		dpopKeyPair,
307 +
		config.scope,
308 +
	);
309 +
310 +
	await storeAuthState(kv, state, pkce.codeVerifier, dpopKeyPair, dpopNonce);
311 +
312 +
	const authUrl = buildAuthorizationUrl(
313 +
		metadata,
314 +
		parResponse.request_uri,
315 +
		config.clientId,
316 +
	);
317 +
318 +
	return { authUrl, state };
319 +
}
320 +
321 +
export interface CompleteOAuthResult {
322 +
	tokenResponse: TokenResponse;
323 +
	dpopKeyPair: DPoPKeyPair;
324 +
	dpopNonce: string;
325 +
}
326 +
327 +
/**
328 +
 * Completes an OAuth callback - validates state, exchanges code for tokens
329 +
 */
330 +
export async function completeOAuthFlow(
331 +
	kv: KVNamespace,
332 +
	pdsUrl: string,
333 +
	code: string,
334 +
	state: string,
335 +
	clientId: string,
336 +
	redirectUri: string,
337 +
): Promise<CompleteOAuthResult | null> {
338 +
	const authState = await getAndDeleteAuthState(kv, state);
339 +
	if (!authState) {
340 +
		return null;
341 +
	}
342 +
343 +
	const metadata = await fetchOAuthMetadata(pdsUrl);
344 +
345 +
	const { tokenResponse, dpopNonce } = await exchangeCodeForTokens(
346 +
		metadata,
347 +
		code,
348 +
		authState.codeVerifier,
349 +
		clientId,
350 +
		redirectUri,
351 +
		authState.dpopKeyPair,
352 +
		authState.dpopNonce,
353 +
	);
354 +
355 +
	return {
356 +
		tokenResponse,
357 +
		dpopKeyPair: authState.dpopKeyPair,
358 +
		dpopNonce,
267 359
	};
268 360
}
269 361
packages/server/src/routes/auth.ts +18 −58
1 1
import { Hono } from "hono";
2 -
import { generateDPoPKeyPair } from "../lib/dpop";
3 2
import {
4 3
	fetchOAuthMetadata,
5 -
	generatePKCE,
6 -
	generateState,
7 -
	sendPAR,
8 -
	buildAuthorizationUrl,
9 -
	exchangeCodeForTokens,
4 +
	refreshAccessToken,
5 +
	initiateOAuthFlow,
6 +
	completeOAuthFlow,
10 7
} from "../lib/oauth";
11 8
import {
12 -
	storeAuthState,
13 -
	getAndDeleteAuthState,
14 9
	createSession,
15 10
	getSession,
16 11
	deleteSession,
20 15
	isTokenExpired,
21 16
	updateSession,
22 17
} from "../lib/session";
23 -
import { refreshAccessToken } from "../lib/oauth";
24 18
25 19
interface Env {
26 20
	SESSIONS: KVNamespace;
57 51
		const clientId = `${c.env.API_URL}/auth/client-metadata.json`;
58 52
		const redirectUri = `${c.env.API_URL}/auth/callback`;
59 53
60 -
		// Fetch OAuth metadata from PDS
61 -
		const metadata = await fetchOAuthMetadata(c.env.PDS_URL);
62 -
63 -
		// Generate PKCE and state
64 -
		const pkce = await generatePKCE();
65 -
		const state = generateState();
66 -
67 -
		// Generate DPoP keypair
68 -
		const dpopKeyPair = await generateDPoPKeyPair();
69 -
70 -
		// Send PAR request
71 -
		const { parResponse, dpopNonce } = await sendPAR(
72 -
			metadata,
54 +
		const { authUrl } = await initiateOAuthFlow(c.env.SESSIONS, {
55 +
			pdsUrl: c.env.PDS_URL,
73 56
			clientId,
74 57
			redirectUri,
75 -
			state,
76 -
			pkce,
77 -
			dpopKeyPair,
78 -
			"atproto transition:generic",
79 -
		);
80 -
81 -
		// Store auth state in KV
82 -
		await storeAuthState(
83 -
			c.env.SESSIONS,
84 -
			state,
85 -
			pkce.codeVerifier,
86 -
			dpopKeyPair,
87 -
			dpopNonce,
88 -
		);
58 +
			scope: "atproto transition:generic",
59 +
		});
89 60
90 -
		// Build authorization URL and redirect
91 -
		const authUrl = buildAuthorizationUrl(
92 -
			metadata,
93 -
			parResponse.request_uri,
94 -
			clientId,
95 -
		);
96 61
		return c.redirect(authUrl);
97 62
	} catch (error) {
98 63
		console.error("Login error:", error);
120 85
			return c.redirect(`${c.env.CLIENT_URL}/now?error=missing_params`);
121 86
		}
122 87
123 -
		// Retrieve and validate auth state
124 -
		const authState = await getAndDeleteAuthState(c.env.SESSIONS, state);
125 -
		if (!authState) {
126 -
			return c.redirect(`${c.env.CLIENT_URL}/now?error=invalid_state`);
127 -
		}
128 -
129 88
		const clientId = `${c.env.API_URL}/auth/client-metadata.json`;
130 89
		const redirectUri = `${c.env.API_URL}/auth/callback`;
131 90
132 -
		// Fetch OAuth metadata
133 -
		const metadata = await fetchOAuthMetadata(c.env.PDS_URL);
134 -
135 -
		// Exchange code for tokens
136 -
		const { tokenResponse, dpopNonce } = await exchangeCodeForTokens(
137 -
			metadata,
91 +
		const oauthResult = await completeOAuthFlow(
92 +
			c.env.SESSIONS,
93 +
			c.env.PDS_URL,
138 94
			code,
139 -
			authState.codeVerifier,
95 +
			state,
140 96
			clientId,
141 97
			redirectUri,
142 -
			authState.dpopKeyPair,
143 -
			authState.dpopNonce,
144 98
		);
99 +
100 +
		if (!oauthResult) {
101 +
			return c.redirect(`${c.env.CLIENT_URL}/now?error=invalid_state`);
102 +
		}
103 +
104 +
		const { tokenResponse, dpopKeyPair, dpopNonce } = oauthResult;
145 105
146 106
		// CRITICAL: Only allow the site owner
147 107
		if (tokenResponse.sub !== c.env.ALLOWED_DID) {
154 114
			c.env.SESSIONS,
155 115
			tokenResponse.access_token,
156 116
			tokenResponse.refresh_token || "",
157 -
			authState.dpopKeyPair,
117 +
			dpopKeyPair,
158 118
			dpopNonce,
159 119
			tokenResponse.sub,
160 120
			tokenResponse.expires_in,
packages/server/src/routes/guest-auth.ts +17 −57
1 1
import { Hono } from "hono";
2 -
import { generateDPoPKeyPair } from "../lib/dpop";
3 2
import {
4 3
	fetchOAuthMetadata,
5 -
	generatePKCE,
6 -
	generateState,
7 -
	sendPAR,
8 -
	buildAuthorizationUrl,
9 -
	exchangeCodeForTokens,
10 4
	refreshAccessToken,
5 +
	initiateOAuthFlow,
6 +
	completeOAuthFlow,
11 7
} from "../lib/oauth";
12 8
import {
13 -
	storeAuthState,
14 -
	getAndDeleteAuthState,
15 9
	createSession,
16 10
	getSession,
17 11
	deleteSession,
136 130
			return c.redirect(`${c.env.CLIENT_URL}/now?error=invalid_handle`);
137 131
		}
138 132
139 -
		// Fetch OAuth metadata from user's PDS
140 -
		const metadata = await fetchOAuthMetadata(pdsUrl);
141 -
142 -
		// Generate PKCE and state
143 -
		const pkce = await generatePKCE();
144 -
		const state = generateState();
145 -
146 -
		// Generate DPoP keypair
147 -
		const dpopKeyPair = await generateDPoPKeyPair();
148 -
149 -
		// Send PAR request
150 -
		const { parResponse, dpopNonce } = await sendPAR(
151 -
			metadata,
133 +
		const { authUrl, state } = await initiateOAuthFlow(c.env.SESSIONS, {
134 +
			pdsUrl,
152 135
			clientId,
153 136
			redirectUri,
154 -
			state,
155 -
			pkce,
156 -
			dpopKeyPair,
157 -
			"atproto repo:site.standard.document.comment?action=create",
158 -
		);
159 -
160 -
		// Store auth state in KV with returnTo URL and PDS URL
161 -
		await storeAuthState(
162 -
			c.env.SESSIONS,
163 -
			state,
164 -
			pkce.codeVerifier,
165 -
			dpopKeyPair,
166 -
			dpopNonce,
167 -
		);
137 +
			scope: "atproto repo:site.standard.document.comment?action=create",
138 +
		});
168 139
169 140
		// Store returnTo and pdsUrl separately to retrieve after callback
170 141
		await c.env.SESSIONS.put(
178 149
			{ expirationTtl: 600 }, // 10 minutes
179 150
		);
180 151
181 -
		// Build authorization URL and redirect
182 -
		const authUrl = buildAuthorizationUrl(
183 -
			metadata,
184 -
			parResponse.request_uri,
185 -
			clientId,
186 -
		);
187 152
		return c.redirect(authUrl);
188 153
	} catch (error) {
189 154
		console.error("Guest login error:", error);
211 176
			return c.redirect(`${c.env.CLIENT_URL}/now?error=missing_params`);
212 177
		}
213 178
214 -
		// Retrieve and validate auth state
215 -
		const authState = await getAndDeleteAuthState(c.env.SESSIONS, state);
216 -
		if (!authState) {
217 -
			return c.redirect(`${c.env.CLIENT_URL}/now?error=invalid_state`);
218 -
		}
219 -
220 179
		// Get return URL and PDS URL
221 180
		const returnTo =
222 181
			(await c.env.SESSIONS.get(`guest_return:${state}`)) || "/now";
231 190
		const clientId = `${c.env.API_URL}/guest-auth/client-metadata.json`;
232 191
		const redirectUri = `${c.env.API_URL}/guest-auth/callback`;
233 192
234 -
		// Fetch OAuth metadata from user's PDS
235 -
		const metadata = await fetchOAuthMetadata(pdsUrl);
236 -
237 -
		// Exchange code for tokens
238 -
		const { tokenResponse, dpopNonce } = await exchangeCodeForTokens(
239 -
			metadata,
193 +
		const oauthResult = await completeOAuthFlow(
194 +
			c.env.SESSIONS,
195 +
			pdsUrl,
240 196
			code,
241 -
			authState.codeVerifier,
197 +
			state,
242 198
			clientId,
243 199
			redirectUri,
244 -
			authState.dpopKeyPair,
245 -
			authState.dpopNonce,
246 200
		);
247 201
202 +
		if (!oauthResult) {
203 +
			return c.redirect(`${c.env.CLIENT_URL}/now?error=invalid_state`);
204 +
		}
205 +
206 +
		const { tokenResponse, dpopKeyPair, dpopNonce } = oauthResult;
207 +
248 208
		// For guests, allow any ATProto account (no DID check)
249 209
		// Create session with a "guest_" prefix to differentiate from admin sessions
250 210
		const sessionId = await createSession(
251 211
			c.env.SESSIONS,
252 212
			tokenResponse.access_token,
253 213
			tokenResponse.refresh_token || "",
254 -
			authState.dpopKeyPair,
214 +
			dpopKeyPair,
255 215
			dpopNonce,
256 216
			tokenResponse.sub,
257 217
			tokenResponse.expires_in,
packages/server/src/routes/now.ts +10 −103
20 20
21 21
const now = new Hono<{ Bindings: Env }>();
22 22
23 -
const DID = "did:plc:ia2zdnhjaokf5lazhxrmj6eu";
24 23
const PDS_URL = "https://polybius.social";
25 24
26 25
// Helper function to get session for both admin and guest users
220 219
		const response = await fetch(
221 220
			`${PDS_URL}/xrpc/com.atproto.repo.listRecords?` +
222 221
				new URLSearchParams({
223 -
					repo: DID,
222 +
					repo: c.env.ALLOWED_DID,
224 223
					collection: "site.standard.document",
225 224
					limit: "50",
226 225
				}),
548 547
				const rkey = parts[4];
549 548
550 549
				// Resolve the DID to find the PDS endpoint
551 -
				const didDoc = await fetch(`https://plc.directory/${did}`).then((r) =>
550 +
				const didDoc = (await fetch(`https://plc.directory/${did}`).then((r) =>
552 551
					r.json(),
553 -
				);
552 +
				)) as {
553 +
					service?: Array<{ type: string; serviceEndpoint: string }>;
554 +
				};
554 555
555 556
				// Find the PDS service endpoint
556 557
				const pdsService = didDoc.service?.find(
557 -
					(s: any) => s.type === "AtprotoPersonalDataServer",
558 +
					(s) => s.type === "AtprotoPersonalDataServer",
558 559
				);
559 560
560 561
				if (!pdsService?.serviceEndpoint) {
581 582
					return null;
582 583
				}
583 584
584 -
				const data = await recordResponse.json();
585 +
				const data = (await recordResponse.json()) as {
586 +
					value: Record<string, unknown>;
587 +
					cid: string;
588 +
				};
585 589
				return {
586 590
					...data.value,
587 591
					uri: ref.uri,
597 601
		const validComments = comments.filter((comment) => comment !== null);
598 602
599 603
		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)
607 -
now.get("/replies/:uri", async (c) => {
608 -
	try {
609 -
		const encodedUri = c.req.param("uri");
610 -
		const uri = decodeURIComponent(encodedUri);
611 -
612 -
		// Get the parent post to find its CID for matching
613 -
		const getRecordUrl =
614 -
			`${PDS_URL}/xrpc/com.atproto.repo.getRecord?` +
615 -
			new URLSearchParams({
616 -
				repo: uri.split("/")[2], // Extract DID from URI
617 -
				collection: uri.split("/")[3], // Extract collection
618 -
				rkey: uri.split("/")[4], // Extract rkey
619 -
			});
620 -
621 -
		const parentResponse = await fetch(getRecordUrl);
622 -
		if (!parentResponse.ok) {
623 -
			console.error("Failed to fetch parent post:", parentResponse.status);
624 -
			return c.json({ replies: [] });
625 -
		}
626 -
627 -
		const parentData = (await parentResponse.json()) as { cid: string };
628 -
		const parentCid = parentData.cid;
629 -
630 -
		// Fetch all site.standard.document.comment records
631 -
		// Note: This is a simple implementation that fetches all comments
632 -
		// In production, you'd want to filter by parent URI server-side if possible
633 -
		const listUrl =
634 -
			`${PDS_URL}/xrpc/com.atproto.repo.listRecords?` +
635 -
			new URLSearchParams({
636 -
				repo: DID,
637 -
				collection: "site.standard.document.comment",
638 -
				limit: "100",
639 -
			});
640 -
641 -
		const response = await fetch(listUrl);
642 -
643 -
		if (!response.ok) {
644 -
			console.error("Failed to fetch comments:", response.status);
645 -
			return c.json({ replies: [] });
646 -
		}
647 -
648 -
		interface CommentRecord {
649 -
			uri: string;
650 -
			cid: string;
651 -
			value: {
652 -
				parent?: { uri?: string; cid?: string };
653 -
				content: string;
654 -
				createdAt: string;
655 -
				author?: { handle?: string; displayName?: string; avatar?: string };
656 -
			};
657 -
			indexedAt?: string;
658 -
		}
659 -
660 -
		const data = (await response.json()) as { records: CommentRecord[] };
661 -
662 -
		// Filter comments that match the parent URI
663 -
		const replies: any[] = [];
664 -
665 -
		for (const record of data.records) {
666 -
			const comment = record.value;
667 -
			// Check if this comment's parent matches our URI
668 -
			if (comment.parent?.uri === uri || comment.parent?.cid === parentCid) {
669 -
				replies.push({
670 -
					uri: record.uri,
671 -
					cid: record.cid,
672 -
					author: {
673 -
						did: record.uri.split("/")[2],
674 -
						handle: comment.author?.handle || record.uri.split("/")[2],
675 -
						displayName: comment.author?.displayName,
676 -
						avatar: comment.author?.avatar,
677 -
					},
678 -
					record: {
679 -
						text: comment.content,
680 -
						createdAt: comment.createdAt,
681 -
					},
682 -
					indexedAt: record.indexedAt || comment.createdAt,
683 -
					replyCount: 0,
684 -
					likeCount: 0,
685 -
				});
686 -
			}
687 -
		}
688 -
689 -
		// Sort by creation date
690 -
		replies.sort(
691 -
			(a, b) =>
692 -
				new Date(a.record.createdAt).getTime() -
693 -
				new Date(b.record.createdAt).getTime(),
694 -
		);
695 -
696 -
		return c.json({ replies });
697 604
	} catch (error) {
698 605
		console.error("Error fetching comments:", error);
699 606
		return c.json({ replies: [] });