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.
at master 287 lines 8.8 kB view raw
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}