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.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382import { 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";
/** * OAuth scope requested by this app. * * Every collection the app writes must be listed here or the user's PDS rejects * the write. Keep it in sync with the `collection:` values used in `src/routes`. */export const OAUTH_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.game?action=delete repo:boo.sky.go.action?action=create repo:boo.sky.go.action?action=delete repo:boo.sky.go.move?action=create repo:boo.sky.go.move?action=delete repo:boo.sky.go.pass?action=create repo:boo.sky.go.pass?action=delete repo:boo.sky.go.resign?action=create repo:boo.sky.go.resign?action=delete repo:boo.sky.go.reaction?action=create repo:boo.sky.go.profile?action=create repo:boo.sky.go.profile?action=update";
/** * Path of the client metadata document, which doubles as the OAuth `client_id`. * * Authorization servers cache this document by URL and there is no guaranteed * TTL, so **bump the version here whenever the scope above changes**: a server * still holding the previous copy rejects the new scope with "not declared in * the client metadata". The route matches any version, so bumping needs no new * files. Bumping does sign every user out once, since sessions are bound to the * client_id that created them. */export const OAUTH_CLIENT_METADATA_PATH = "/oauth/v2/client-metadata.json";
/** * Is this origin a loopback address (`localhost`, `127.0.0.0/8`, `[::1]`)? * * Loopback origins get a different OAuth client — the atproto spec's localhost * exception lets such a client skip publishing metadata altogether, see * `getOAuthClient`. */export function isLoopbackOrigin(origin: string): boolean { try { const { hostname } = new URL(origin); return ( hostname === "localhost" || hostname === "::1" || hostname.endsWith(".localhost") || /^127\./.test(hostname) ); } catch { return false; }}
// Fetch-based DNS resolver for Cloudflare Workersclass FetchDnsHandleResolver { async resolve(handle: string): Promise<string | null> { 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 Workersclass CloudflarePlcDidDocumentResolver { private plcUrl = 'https://plc.directory';
async resolve(did: string): Promise<any> { 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 Workersclass CloudflareWebDidDocumentResolver { async resolve(did: string): Promise<any> { 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 Workersclass KVStateStore<K extends string = string, V = any> { constructor( private kv: KVNamespace, private prefix: string, private ttlSeconds?: number ) {}
async set(key: K, value: V): Promise<void> { await this.kv.put( `${this.prefix}:${key}`, JSON.stringify(value), this.ttlSeconds ? { expirationTtl: this.ttlSeconds } : undefined ); }
async get(key: K): Promise<V | undefined> { const value = await this.kv.get(`${this.prefix}:${key}`, 'json'); return value || undefined; }
async delete(key: K): Promise<void> { 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 instancesconst oauthClients = new Map<string, OAuthClient>();
export async function getOAuthClient(platform: App.Platform | undefined): Promise<OAuthClient> { // 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 (!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);
const 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, }, }), });
const 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<string, StoredState>( 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<string, StoredState>({ maxSize: 10_000, ttl: TEN_MINUTES_MS, ttlAutopurge: true, }) as any, };
let client: OAuthClient;
// The atproto spec allows a `http://localhost` client_id for local // development: the authorization server synthesises the client metadata from // the client_id's own query parameters, so nothing has to be published and no // signing key is involved - it is a public client, not a confidential one. // Note the asymmetry the spec requires: the client_id is `http://localhost`, // while the redirect URI has to be on 127.0.0.1 or [::1]. if (isLoopbackOrigin(PUBLIC_BASE_URL)) { client = new OAuthClient({ metadata: { redirect_uris: [ `http://127.0.0.1:${publicUrl.port || "80"}/auth/callback`, ], scope: OAUTH_SCOPE, }, actorResolver, stores, }); } else { if (!PRIVATE_KEY_JWK) { throw new Error( "PRIVATE_KEY_JWK environment variable is required for OAuth", ); }
client = new OAuthClient({ metadata: { client_id: new URL(OAUTH_CLIENT_METADATA_PATH, publicUrl).href, client_name: "Cloud Go", redirect_uris: [new URL("/auth/callback", publicUrl).href], scope: OAUTH_SCOPE, jwks_uri: new URL("/jwks.json", publicUrl).href, },
// Parse the JWK directly - the new API accepts JWK objects keyset: [JSON.parse(PRIVATE_KEY_JWK) as ClientAssertionPrivateJwk],
actorResolver, stores, }); }
oauthClients.set(cacheKey, client); return client; return client;}
export interface Session { did: string; handle?: string;}
const COOKIE_NAME = "go_session";
/** * Is this restore failure definitive (the stored session is unusable) or * transient (a retry could recover it)? */function isSessionGone(error: unknown): boolean { const err = error as { status?: number; error?: string; message?: string } | null | undefined; const described = `${err?.error ?? ""} ${err?.message ?? ""}`.toLowerCase();
if (/invalid_grant|invalid_token|expired_token|session not found|no session/.test(described)) { return true; } // 401/403 from the token endpoint means the refresh token was rejected. // 5xx responses and transport errors do not. return err?.status === 401 || err?.status === 403;}
export async function getSession(event: RequestEvent): Promise<Session | null> { 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) { // Only discard the cookie when the session is definitively unusable. // Anything else (network blip, cold KV read, a 5xx from the PDS) is // transient, and deleting the cookie on those turned a momentary failure // into a permanent logout. if (isSessionGone(error)) { event.cookies.delete(COOKIE_NAME, { path: "/" }); } else { console.error("[getSession] transient session restore failure, keeping cookie:", error); } 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 * 30, // 30 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<Client | null> { 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; }}