import { type RequestEvent } from "@sveltejs/kit"; import type { App } from '@sveltejs/kit'; import { OAuthClient, MemoryStore, type StoredState, type OAuthSession, } from "@atcute/oauth-node-client"; import type { ClientAssertionPrivateJwk } from "@atcute/oauth-crypto"; import { CompositeDidDocumentResolver, CompositeHandleResolver, LocalActorResolver, WellKnownHandleResolver, } from "@atcute/identity-resolver"; import type { Did } from "@atcute/lexicons/syntax"; import { Client } from "@atcute/client"; // Fetch-based DNS resolver for Cloudflare Workers class FetchDnsHandleResolver { async resolve(handle: string): Promise { try { const dnsUrl = `https://cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`; const response = await fetch(dnsUrl, { headers: { 'Accept': 'application/dns-json' } }); if (!response.ok) return null; const data: any = await response.json(); const txtRecords = data.Answer?.filter((a: any) => a.type === 16) || []; for (const record of txtRecords) { const text = record.data.replace(/"/g, ''); if (text.startsWith('did=')) { return text.substring(4); } } return null; } catch { return null; } } } // Custom PLC DID resolver with better error handling for Cloudflare Workers class CloudflarePlcDidDocumentResolver { private plcUrl = 'https://plc.directory'; async resolve(did: string): Promise { if (!did.startsWith('did:plc:')) { return undefined; } try { const url = `${this.plcUrl}/${did}`; const response = await fetch(url, { headers: { 'Accept': 'application/json', 'User-Agent': 'CloudGo/1.0', }, }); if (!response.ok) { console.error('[PLC Resolver] HTTP error:', response.status, did); return undefined; } return await response.json(); } catch (err) { console.error('[PLC Resolver] Fetch error:', err); return undefined; } } } // Custom Web DID resolver for Cloudflare Workers class CloudflareWebDidDocumentResolver { async resolve(did: string): Promise { if (!did.startsWith('did:web:')) { return undefined; } try { // did:web:example.com -> https://example.com/.well-known/did.json // did:web:example.com:path:to -> https://example.com/path/to/did.json const domainAndPath = did.slice('did:web:'.length); const parts = domainAndPath.split(':').map(decodeURIComponent); const domain = parts[0]; const path = parts.length > 1 ? '/' + parts.slice(1).join('/') : '/.well-known'; const url = `https://${domain}${path}/did.json`; const response = await fetch(url, { headers: { 'Accept': 'application/json', 'User-Agent': 'CloudGo/1.0', }, }); if (!response.ok) { console.error('[Web Resolver] HTTP error:', response.status, did); return undefined; } return await response.json(); } catch (err) { console.error('[Web Resolver] Fetch error:', err); return undefined; } } } // KV-backed state store for Cloudflare Workers class KVStateStore { constructor( private kv: KVNamespace, private prefix: string, private ttlSeconds?: number ) {} async set(key: K, value: V): Promise { await this.kv.put( `${this.prefix}:${key}`, JSON.stringify(value), this.ttlSeconds ? { expirationTtl: this.ttlSeconds } : undefined ); } async get(key: K): Promise { const value = await this.kv.get(`${this.prefix}:${key}`, 'json'); return value || undefined; } async delete(key: K): Promise { await this.kv.delete(`${this.prefix}:${key}`); } } const ONE_MINUTE_MS = 60_000; const TEN_MINUTES_MS = 10 * ONE_MINUTE_MS; // Module-level cache for OAuth client instances const oauthClients = new Map(); export async function getOAuthClient(platform: App.Platform | undefined): Promise { // Get env vars from platform (Cloudflare) or process.env (local dev) const env = platform?.env; const PRIVATE_KEY_JWK = env?.PRIVATE_KEY_JWK ?? (typeof process !== 'undefined' ? process.env?.PRIVATE_KEY_JWK : undefined); const PUBLIC_BASE_URL = env?.PUBLIC_BASE_URL ?? (typeof process !== 'undefined' ? process.env?.PUBLIC_BASE_URL : undefined); if (!PRIVATE_KEY_JWK) { throw new Error( "PRIVATE_KEY_JWK environment variable is required for OAuth", ); } if (!PUBLIC_BASE_URL) { throw new Error( "PUBLIC_BASE_URL environment variable is required for OAuth. Check your configuration.", ); } // Use a cache key based on the base URL const cacheKey = PUBLIC_BASE_URL; const cached = oauthClients.get(cacheKey); if (cached) { return cached; } const publicUrl = new URL(PUBLIC_BASE_URL); // Parse the JWK directly - the new API accepts JWK objects const privateJwk = JSON.parse(PRIVATE_KEY_JWK) as ClientAssertionPrivateJwk; const client = new OAuthClient({ metadata: { client_id: new URL("/oauth-client-metadata.json", publicUrl).href, client_name: "Cloud Go", redirect_uris: [new URL("/auth/callback", publicUrl).href], scope: "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", jwks_uri: new URL("/jwks.json", publicUrl).href, }, keyset: [privateJwk], actorResolver: new LocalActorResolver({ handleResolver: new CompositeHandleResolver({ methods: { dns: new FetchDnsHandleResolver() as any, // Type cast for compatibility http: new WellKnownHandleResolver(), }, }), didDocumentResolver: new CompositeDidDocumentResolver({ methods: { plc: new CloudflarePlcDidDocumentResolver() as any, web: new CloudflareWebDidDocumentResolver() as any, }, }), }), stores: platform?.env?.SESSIONS_KV && platform?.env?.STATES_KV ? { // Production: Use KV storage sessions: new KVStateStore(platform.env.SESSIONS_KV, 'session') as any, states: new KVStateStore(platform.env.STATES_KV, 'state', 600) as any, // 10 min TTL } : { // Local development: Use MemoryStore sessions: new MemoryStore({ maxSize: 10_000 }) as any, states: new MemoryStore({ maxSize: 10_000, ttl: TEN_MINUTES_MS, ttlAutopurge: true, }) as any, }, }); oauthClients.set(cacheKey, client); return client; } export interface Session { did: string; handle?: string; } const COOKIE_NAME = "go_session"; export async function getSession(event: RequestEvent): Promise { const did = event.cookies.get(COOKIE_NAME); if (!did) return null; try { const oauth = await getOAuthClient(event.platform); const oauthSession = await oauth.restore(did as Did, { refresh: "auto" }); // Return session with DID and optionally fetch handle if needed return { did: oauthSession.did, }; } catch (error) { // If session restore fails, clear the cookie event.cookies.delete(COOKIE_NAME, { path: "/" }); return null; } } export async function setSession(event: RequestEvent, session: Session) { const PUBLIC_BASE_URL = event.platform?.env?.PUBLIC_BASE_URL; const secure = PUBLIC_BASE_URL?.startsWith("https:") ?? false; event.cookies.set(COOKIE_NAME, session.did, { path: "/", httpOnly: true, sameSite: "lax", secure, maxAge: 60 * 60 * 24 * 7, // 7 days }); } export async function clearSession(event: RequestEvent) { const PUBLIC_BASE_URL = event.platform?.env?.PUBLIC_BASE_URL; const secure = PUBLIC_BASE_URL?.startsWith("https:") ?? false; event.cookies.delete(COOKIE_NAME, { path: "/", httpOnly: true, sameSite: "lax", secure, }); } export async function getAgent(event: RequestEvent): Promise { const did = event.cookies.get(COOKIE_NAME); if (!did) return null; try { const oauth = await getOAuthClient(event.platform); const oauthSession = await oauth.restore(did as Did, { refresh: "auto" }); // Create a client using the OAuth session as the handler const client = new Client({ handler: oauthSession }); return client; } catch (error) { console.error("Failed to get agent:", error); return null; } }