extremely claude-assisted go game based on atproto! working on cleaning up and giving a more unique design, still has a bit of a slop vibe to it.
1import { type RequestEvent } from "@sveltejs/kit";
2import type { App } from '@sveltejs/kit';
3import {
4 OAuthClient,
5 MemoryStore,
6 type StoredState,
7 type OAuthSession,
8} from "@atcute/oauth-node-client";
9import type { ClientAssertionPrivateJwk } from "@atcute/oauth-crypto";
10import {
11 CompositeDidDocumentResolver,
12 CompositeHandleResolver,
13 LocalActorResolver,
14 WellKnownHandleResolver,
15} from "@atcute/identity-resolver";
16import type { Did } from "@atcute/lexicons/syntax";
17import { Client } from "@atcute/client";
18
19// Fetch-based DNS resolver for Cloudflare Workers
20class FetchDnsHandleResolver {
21 async resolve(handle: string): Promise<string | null> {
22 try {
23 const dnsUrl = `https://cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`;
24 const response = await fetch(dnsUrl, {
25 headers: { 'Accept': 'application/dns-json' }
26 });
27
28 if (!response.ok) return null;
29
30 const data: any = await response.json();
31 const txtRecords = data.Answer?.filter((a: any) => a.type === 16) || [];
32
33 for (const record of txtRecords) {
34 const text = record.data.replace(/"/g, '');
35 if (text.startsWith('did=')) {
36 return text.substring(4);
37 }
38 }
39 return null;
40 } catch {
41 return null;
42 }
43 }
44}
45
46// Custom PLC DID resolver with better error handling for Cloudflare Workers
47class CloudflarePlcDidDocumentResolver {
48 private plcUrl = 'https://plc.directory';
49
50 async resolve(did: string): Promise<any> {
51 if (!did.startsWith('did:plc:')) {
52 return undefined;
53 }
54
55 try {
56 const url = `${this.plcUrl}/${did}`;
57 const response = await fetch(url, {
58 headers: {
59 'Accept': 'application/json',
60 'User-Agent': 'CloudGo/1.0',
61 },
62 });
63
64 if (!response.ok) {
65 console.error('[PLC Resolver] HTTP error:', response.status, did);
66 return undefined;
67 }
68
69 return await response.json();
70 } catch (err) {
71 console.error('[PLC Resolver] Fetch error:', err);
72 return undefined;
73 }
74 }
75}
76
77// Custom Web DID resolver for Cloudflare Workers
78class CloudflareWebDidDocumentResolver {
79 async resolve(did: string): Promise<any> {
80 if (!did.startsWith('did:web:')) {
81 return undefined;
82 }
83
84 try {
85 // did:web:example.com -> https://example.com/.well-known/did.json
86 // did:web:example.com:path:to -> https://example.com/path/to/did.json
87 const domainAndPath = did.slice('did:web:'.length);
88 const parts = domainAndPath.split(':').map(decodeURIComponent);
89 const domain = parts[0];
90 const path = parts.length > 1 ? '/' + parts.slice(1).join('/') : '/.well-known';
91 const url = `https://${domain}${path}/did.json`;
92
93 const response = await fetch(url, {
94 headers: {
95 'Accept': 'application/json',
96 'User-Agent': 'CloudGo/1.0',
97 },
98 });
99
100 if (!response.ok) {
101 console.error('[Web Resolver] HTTP error:', response.status, did);
102 return undefined;
103 }
104
105 return await response.json();
106 } catch (err) {
107 console.error('[Web Resolver] Fetch error:', err);
108 return undefined;
109 }
110 }
111}
112
113// KV-backed state store for Cloudflare Workers
114class KVStateStore<K extends string = string, V = any> {
115 constructor(
116 private kv: KVNamespace,
117 private prefix: string,
118 private ttlSeconds?: number
119 ) {}
120
121 async set(key: K, value: V): Promise<void> {
122 await this.kv.put(
123 `${this.prefix}:${key}`,
124 JSON.stringify(value),
125 this.ttlSeconds ? { expirationTtl: this.ttlSeconds } : undefined
126 );
127 }
128
129 async get(key: K): Promise<V | undefined> {
130 const value = await this.kv.get(`${this.prefix}:${key}`, 'json');
131 return value || undefined;
132 }
133
134 async delete(key: K): Promise<void> {
135 await this.kv.delete(`${this.prefix}:${key}`);
136 }
137}
138
139const ONE_MINUTE_MS = 60_000;
140const TEN_MINUTES_MS = 10 * ONE_MINUTE_MS;
141
142// Module-level cache for OAuth client instances
143const oauthClients = new Map<string, OAuthClient>();
144
145export async function getOAuthClient(platform: App.Platform | undefined): Promise<OAuthClient> {
146 // Get env vars from platform (Cloudflare) or process.env (local dev)
147 const env = platform?.env;
148 const PRIVATE_KEY_JWK = env?.PRIVATE_KEY_JWK ?? (typeof process !== 'undefined' ? process.env?.PRIVATE_KEY_JWK : undefined);
149 const PUBLIC_BASE_URL = env?.PUBLIC_BASE_URL ?? (typeof process !== 'undefined' ? process.env?.PUBLIC_BASE_URL : undefined);
150
151 if (!PRIVATE_KEY_JWK) {
152 throw new Error(
153 "PRIVATE_KEY_JWK environment variable is required for OAuth",
154 );
155 }
156
157 if (!PUBLIC_BASE_URL) {
158 throw new Error(
159 "PUBLIC_BASE_URL environment variable is required for OAuth. Check your configuration.",
160 );
161 }
162
163 // Use a cache key based on the base URL
164 const cacheKey = PUBLIC_BASE_URL;
165 const cached = oauthClients.get(cacheKey);
166 if (cached) {
167 return cached;
168 }
169
170 const publicUrl = new URL(PUBLIC_BASE_URL);
171
172 // Parse the JWK directly - the new API accepts JWK objects
173 const privateJwk = JSON.parse(PRIVATE_KEY_JWK) as ClientAssertionPrivateJwk;
174
175 const client = new OAuthClient({
176 metadata: {
177 client_id: new URL("/oauth-client-metadata.json", publicUrl).href,
178 client_name: "Cloud Go",
179 redirect_uris: [new URL("/auth/callback", publicUrl).href],
180 scope:
181 "atproto repo:app.bsky.feed.post?action=create com.atproto.repo.uploadBlob blob:image/png repo:boo.sky.go.game?action=create repo:boo.sky.go.game?action=update repo:boo.sky.go.move?action=create repo:boo.sky.go.pass?action=create repo:boo.sky.go.resign?action=create repo:boo.sky.go.reaction?action=create repo:boo.sky.go.profile?action=create repo:boo.sky.go.profile?action=update",
182 jwks_uri: new URL("/jwks.json", publicUrl).href,
183 },
184
185 keyset: [privateJwk],
186
187 actorResolver: new LocalActorResolver({
188 handleResolver: new CompositeHandleResolver({
189 methods: {
190 dns: new FetchDnsHandleResolver() as any, // Type cast for compatibility
191 http: new WellKnownHandleResolver(),
192 },
193 }),
194 didDocumentResolver: new CompositeDidDocumentResolver({
195 methods: {
196 plc: new CloudflarePlcDidDocumentResolver() as any,
197 web: new CloudflareWebDidDocumentResolver() as any,
198 },
199 }),
200 }),
201
202 stores: platform?.env?.SESSIONS_KV && platform?.env?.STATES_KV ? {
203 // Production: Use KV storage
204 sessions: new KVStateStore(platform.env.SESSIONS_KV, 'session') as any,
205 states: new KVStateStore<string, StoredState>(platform.env.STATES_KV, 'state', 600) as any, // 10 min TTL
206 } : {
207 // Local development: Use MemoryStore
208 sessions: new MemoryStore({ maxSize: 10_000 }) as any,
209 states: new MemoryStore<string, StoredState>({
210 maxSize: 10_000,
211 ttl: TEN_MINUTES_MS,
212 ttlAutopurge: true,
213 }) as any,
214 },
215 });
216
217 oauthClients.set(cacheKey, client);
218 return client;
219}
220
221export interface Session {
222 did: string;
223 handle?: string;
224}
225
226const COOKIE_NAME = "go_session";
227
228export async function getSession(event: RequestEvent): Promise<Session | null> {
229 const did = event.cookies.get(COOKIE_NAME);
230 if (!did) return null;
231
232 try {
233 const oauth = await getOAuthClient(event.platform);
234 const oauthSession = await oauth.restore(did as Did, { refresh: "auto" });
235
236 // Return session with DID and optionally fetch handle if needed
237 return {
238 did: oauthSession.did,
239 };
240 } catch (error) {
241 // If session restore fails, clear the cookie
242 event.cookies.delete(COOKIE_NAME, { path: "/" });
243 return null;
244 }
245}
246
247export async function setSession(event: RequestEvent, session: Session) {
248 const PUBLIC_BASE_URL = event.platform?.env?.PUBLIC_BASE_URL;
249 const secure = PUBLIC_BASE_URL?.startsWith("https:") ?? false;
250
251 event.cookies.set(COOKIE_NAME, session.did, {
252 path: "/",
253 httpOnly: true,
254 sameSite: "lax",
255 secure,
256 maxAge: 60 * 60 * 24 * 7, // 7 days
257 });
258}
259
260export async function clearSession(event: RequestEvent) {
261 const PUBLIC_BASE_URL = event.platform?.env?.PUBLIC_BASE_URL;
262 const secure = PUBLIC_BASE_URL?.startsWith("https:") ?? false;
263
264 event.cookies.delete(COOKIE_NAME, {
265 path: "/",
266 httpOnly: true,
267 sameSite: "lax",
268 secure,
269 });
270}
271
272export async function getAgent(event: RequestEvent): Promise<Client | null> {
273 const did = event.cookies.get(COOKIE_NAME);
274 if (!did) return null;
275
276 try {
277 const oauth = await getOAuthClient(event.platform);
278 const oauthSession = await oauth.restore(did as Did, { refresh: "auto" });
279
280 // Create a client using the OAuth session as the handler
281 const client = new Client({ handler: oauthSession });
282 return client;
283 } catch (error) {
284 console.error("Failed to get agent:", error);
285 return null;
286 }
287}