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.

Add Cloudflare Pages deployment support

- Add custom DID resolvers for Cloudflare Workers (did:plc and did:web)
- Simplify db.ts to D1-only (removes better-sqlite3 for Workers compatibility)
- Remove dotenv dependency from hooks (not needed in Cloudflare)
- Pass platform context to OAuth endpoints for env var access
- Add deploy script and npm run deploy command
- Add backfill script to sync games from UFOS/Constellation to D1
- Configure D1 database binding in wrangler.toml
- Set PUBLIC_BASE_URL to go.sky.boo

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+329 -103
+3 -1
package.json
··· 8 8 "preview": "vite preview", 9 9 "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 10 "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 - "setup:key": "tsx scripts/generate-key.ts" 11 + "setup:key": "tsx scripts/generate-key.ts", 12 + "deploy": "npm run build && npx wrangler pages deploy .svelte-kit/cloudflare", 13 + "backfill": "npx tsx scripts/backfill-games.ts > scripts/backfill-games.sql && npx wrangler d1 execute atprotogo-db --remote --file=scripts/backfill-games.sql" 12 14 }, 13 15 "devDependencies": { 14 16 "@sveltejs/kit": "^2.0.0",
+218
scripts/backfill-games.ts
··· 1 + /** 2 + * Backfill games from UFOS (ATProto record index) into D1 database. 3 + * Also checks Constellation for moves to determine player_two and status. 4 + * 5 + * Usage: 6 + * # Generate SQL file 7 + * npx tsx scripts/backfill-games.ts > scripts/backfill-games.sql 8 + * 9 + * # Run against D1 10 + * npx wrangler d1 execute atprotogo-db --remote --file=scripts/backfill-games.sql 11 + */ 12 + 13 + const UFOS_API = 'https://ufos-api.microcosm.blue'; 14 + const CONSTELLATION_API = 'https://constellation.microcosm.blue/xrpc'; 15 + 16 + interface UfosRecord { 17 + did: string; 18 + collection: string; 19 + rkey: string; 20 + record: { 21 + $type: string; 22 + boardSize: number; 23 + createdAt: string; 24 + playerOne?: string; 25 + playerTwo?: string; 26 + status: string; 27 + handicap?: number; 28 + winner?: string; 29 + }; 30 + time_us: number; 31 + } 32 + 33 + interface ConstellationBacklink { 34 + did: string; 35 + collection: string; 36 + rkey: string; 37 + } 38 + 39 + interface ConstellationResponse { 40 + records: ConstellationBacklink[]; 41 + total: number; 42 + cursor: string | null; 43 + } 44 + 45 + async function fetchAllGames(): Promise<UfosRecord[]> { 46 + const response = await fetch(`${UFOS_API}/records?collection=boo.sky.go.game`); 47 + if (!response.ok) { 48 + throw new Error(`UFOS API error: ${response.status}`); 49 + } 50 + return response.json(); 51 + } 52 + 53 + async function fetchGameMoves(gameUri: string): Promise<ConstellationBacklink[]> { 54 + const allMoves: ConstellationBacklink[] = []; 55 + let cursor: string | undefined; 56 + 57 + try { 58 + do { 59 + const params = new URLSearchParams({ 60 + subject: gameUri, 61 + source: 'boo.sky.go.move:game', 62 + limit: '100', 63 + }); 64 + if (cursor) params.set('cursor', cursor); 65 + 66 + const res = await fetch( 67 + `${CONSTELLATION_API}/blue.microcosm.links.getBacklinks?${params}`, 68 + { headers: { Accept: 'application/json' } } 69 + ); 70 + 71 + if (!res.ok) break; 72 + 73 + const body: ConstellationResponse = await res.json(); 74 + allMoves.push(...body.records); 75 + cursor = body.cursor ?? undefined; 76 + } while (cursor); 77 + } catch (err) { 78 + console.error(`Failed to fetch moves for ${gameUri}:`, err); 79 + } 80 + 81 + return allMoves; 82 + } 83 + 84 + async function fetchGamePasses(gameUri: string): Promise<ConstellationBacklink[]> { 85 + const allPasses: ConstellationBacklink[] = []; 86 + let cursor: string | undefined; 87 + 88 + try { 89 + do { 90 + const params = new URLSearchParams({ 91 + subject: gameUri, 92 + source: 'boo.sky.go.pass:game', 93 + limit: '100', 94 + }); 95 + if (cursor) params.set('cursor', cursor); 96 + 97 + const res = await fetch( 98 + `${CONSTELLATION_API}/blue.microcosm.links.getBacklinks?${params}`, 99 + { headers: { Accept: 'application/json' } } 100 + ); 101 + 102 + if (!res.ok) break; 103 + 104 + const body: ConstellationResponse = await res.json(); 105 + allPasses.push(...body.records); 106 + cursor = body.cursor ?? undefined; 107 + } while (cursor); 108 + } catch (err) { 109 + console.error(`Failed to fetch passes for ${gameUri}:`, err); 110 + } 111 + 112 + return allPasses; 113 + } 114 + 115 + function escapeSQL(str: string | null | undefined): string { 116 + if (!str) return ''; 117 + return str.replace(/'/g, "''"); 118 + } 119 + 120 + async function main() { 121 + console.error('Fetching games from UFOS...'); 122 + const games = await fetchAllGames(); 123 + console.error(`Found ${games.length} games`); 124 + console.error(''); 125 + 126 + // Output SQL INSERT statements 127 + console.log('-- Backfill games from UFOS with Constellation move data'); 128 + console.log('-- Generated at:', new Date().toISOString()); 129 + console.log(`-- Total games: ${games.length}`); 130 + console.log(''); 131 + 132 + for (const game of games) { 133 + const uri = `at://${game.did}/boo.sky.go.game/${game.rkey}`; 134 + const now = new Date().toISOString(); 135 + const record = game.record; 136 + 137 + // The creator is the DID that owns the record 138 + const creatorDid = game.did; 139 + // playerOne might be different from creator in some cases 140 + let playerOne = record.playerOne || creatorDid; 141 + let playerTwo = record.playerTwo || null; 142 + let status = record.status; 143 + let actionCount = 0; 144 + let lastActionType: string | null = null; 145 + 146 + console.error(`Processing ${game.rkey}...`); 147 + 148 + // Fetch moves and passes from Constellation 149 + const [moves, passes] = await Promise.all([ 150 + fetchGameMoves(uri), 151 + fetchGamePasses(uri), 152 + ]); 153 + 154 + actionCount = moves.length + passes.length; 155 + 156 + if (moves.length > 0 || passes.length > 0) { 157 + // Find all unique DIDs that made moves/passes 158 + const playerDids = new Set<string>(); 159 + 160 + for (const move of moves) { 161 + playerDids.add(move.did); 162 + } 163 + 164 + for (const pass of passes) { 165 + playerDids.add(pass.did); 166 + } 167 + 168 + // If we have moves but no playerTwo in the record, try to determine it 169 + if (!playerTwo && playerDids.size > 0) { 170 + // playerTwo is any DID that's not playerOne 171 + for (const did of playerDids) { 172 + if (did !== playerOne) { 173 + playerTwo = did; 174 + break; 175 + } 176 + } 177 + } 178 + 179 + // Update status to active if there are moves and status is waiting 180 + if (status === 'waiting' && (moves.length > 0 || passes.length > 0)) { 181 + status = 'active'; 182 + } 183 + 184 + // Determine last action type (we don't have timestamps from backlinks, so just use 'move' or 'pass') 185 + if (passes.length > 0) { 186 + lastActionType = 'pass'; 187 + } else if (moves.length > 0) { 188 + lastActionType = 'move'; 189 + } 190 + } 191 + 192 + console.error(` -> ${moves.length} moves, ${passes.length} passes, status: ${status}, playerTwo: ${playerTwo || 'none'}`); 193 + 194 + const sql = `INSERT OR REPLACE INTO games (id, rkey, creator_did, player_one, player_two, board_size, status, action_count, last_action_type, winner, handicap, created_at, updated_at) VALUES ( 195 + '${escapeSQL(uri)}', 196 + '${escapeSQL(game.rkey)}', 197 + '${escapeSQL(creatorDid)}', 198 + '${escapeSQL(playerOne)}', 199 + ${playerTwo ? `'${escapeSQL(playerTwo)}'` : 'NULL'}, 200 + ${record.boardSize}, 201 + '${escapeSQL(status)}', 202 + ${actionCount}, 203 + ${lastActionType ? `'${escapeSQL(lastActionType)}'` : 'NULL'}, 204 + ${record.winner ? `'${escapeSQL(record.winner)}'` : 'NULL'}, 205 + ${record.handicap || 0}, 206 + '${escapeSQL(record.createdAt)}', 207 + '${escapeSQL(now)}' 208 + );`; 209 + console.log(sql); 210 + console.log(''); 211 + } 212 + 213 + console.error(''); 214 + console.error('Done! Run with:'); 215 + console.error(' npx wrangler d1 execute atprotogo-db --remote --file=scripts/backfill-games.sql'); 216 + } 217 + 218 + main().catch(console.error);
+10
scripts/deploy.sh
··· 1 + #!/bin/bash 2 + set -e 3 + 4 + echo "Building..." 5 + npm run build 6 + 7 + echo "Deploying to Cloudflare Pages..." 8 + npx wrangler pages deploy .svelte-kit/cloudflare 9 + 10 + echo "Done!"
-4
src/hooks.server.ts
··· 1 1 import { subscribeToFirehose } from '$lib/server/firehose'; 2 2 import type { Handle } from '@sveltejs/kit'; 3 - import { config } from 'dotenv'; 4 - 5 - // Load .env file for local development (ignored in Cloudflare) 6 - config(); 7 3 8 4 // Initialize firehose on server start (platform context will be passed on first request) 9 5 let firehoseInitialized = false;
+73 -7
src/lib/server/auth.ts
··· 11 11 CompositeDidDocumentResolver, 12 12 CompositeHandleResolver, 13 13 LocalActorResolver, 14 - PlcDidDocumentResolver, 15 - WebDidDocumentResolver, 16 14 WellKnownHandleResolver, 17 15 } from "@atcute/identity-resolver"; 18 16 import type { Did } from "@atcute/lexicons/syntax"; ··· 45 43 } 46 44 } 47 45 46 + // Custom PLC DID resolver with better error handling for Cloudflare Workers 47 + class 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 78 + class 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 + 48 113 // KV-backed state store for Cloudflare Workers 49 114 class KVStateStore<K extends string = string, V = any> { 50 115 constructor( ··· 78 143 const oauthClients = new Map<string, OAuthClient>(); 79 144 80 145 export async function getOAuthClient(platform: App.Platform | undefined): Promise<OAuthClient> { 81 - // For local development, fall back to process.env 82 - const PRIVATE_KEY_JWK = platform?.env?.PRIVATE_KEY_JWK || process.env.PRIVATE_KEY_JWK; 83 - const PUBLIC_BASE_URL = platform?.env?.PUBLIC_BASE_URL || process.env.PUBLIC_BASE_URL; 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); 84 150 85 151 if (!PRIVATE_KEY_JWK) { 86 152 throw new Error( ··· 127 193 }), 128 194 didDocumentResolver: new CompositeDidDocumentResolver({ 129 195 methods: { 130 - plc: new PlcDidDocumentResolver(), 131 - web: new WebDidDocumentResolver(), 196 + plc: new CloudflarePlcDidDocumentResolver() as any, 197 + web: new CloudflareWebDidDocumentResolver() as any, 132 198 }, 133 199 }), 134 200 }),
+7 -80
src/lib/server/db.ts
··· 1 - import { Kysely, SqliteDialect } from 'kysely'; 1 + import { Kysely } from 'kysely'; 2 2 import { D1Dialect } from 'kysely-d1'; 3 3 import type { App } from '@sveltejs/kit'; 4 - import { createRequire } from 'module'; 5 - 6 - const require = createRequire(import.meta.url); 7 4 8 5 export interface GameRecord { 9 6 id: string; // AT URI (game_at_uri) ··· 25 22 games: GameRecord; 26 23 } 27 24 28 - // Local development database (SQLite) 29 - let localDb: Kysely<Database> | null = null; 30 - 31 - function initLocalDb(): Kysely<Database> { 32 - if (localDb) { 33 - console.log('[DB] Returning cached local database'); 34 - return localDb; 35 - } 36 - 37 - try { 38 - // Use CommonJS require for local development 39 - const Database = require('better-sqlite3'); 40 - const fs = require('fs'); 41 - const path = require('path'); 42 - 43 - const dbPath = process.env.DATABASE_PATH || './data/app.db'; 44 - const dbDir = path.dirname(dbPath); 45 - 46 - if (!fs.existsSync(dbDir)) { 47 - fs.mkdirSync(dbDir, { recursive: true }); 48 - } 49 - 50 - const sqlite = new Database(dbPath); 51 - sqlite.pragma('journal_mode = WAL'); 52 - 53 - // Initialize tables 54 - sqlite.exec(` 55 - CREATE TABLE IF NOT EXISTS games ( 56 - id TEXT PRIMARY KEY, 57 - rkey TEXT NOT NULL, 58 - creator_did TEXT NOT NULL, 59 - player_one TEXT NOT NULL, 60 - player_two TEXT, 61 - board_size INTEGER NOT NULL DEFAULT 19, 62 - status TEXT NOT NULL CHECK(status IN ('waiting', 'active', 'completed')), 63 - action_count INTEGER NOT NULL DEFAULT 0, 64 - last_action_type TEXT, 65 - winner TEXT, 66 - handicap INTEGER DEFAULT 0, 67 - created_at TEXT NOT NULL, 68 - updated_at TEXT NOT NULL 69 - ); 70 - 71 - CREATE INDEX IF NOT EXISTS idx_games_status ON games(status); 72 - CREATE INDEX IF NOT EXISTS idx_games_player_one ON games(player_one); 73 - CREATE INDEX IF NOT EXISTS idx_games_player_two ON games(player_two); 74 - CREATE INDEX IF NOT EXISTS idx_games_rkey ON games(rkey); 75 - CREATE INDEX IF NOT EXISTS idx_games_creator_did ON games(creator_did); 76 - `); 77 - 78 - localDb = new Kysely<Database>({ 79 - dialect: new SqliteDialect({ database: sqlite }), 80 - }); 81 - 82 - console.log('[DB] Local database initialized successfully, type:', typeof localDb, 'hasSelectFrom:', typeof localDb.selectFrom); 83 - 84 - // Verify it works 85 - if (typeof localDb.selectFrom !== 'function') { 86 - throw new Error('Kysely instance does not have selectFrom method!'); 87 - } 88 - 89 - return localDb; 90 - } catch (err) { 91 - console.error('[DB] Error initializing local database:', err); 92 - throw err; 93 - } 94 - } 95 - 96 25 export function getDb(platform: App.Platform | undefined): Kysely<Database> { 97 26 // Production: Use Cloudflare D1 98 27 if (platform?.env?.DB) { 99 - const d1db = new Kysely<Database>({ 28 + return new Kysely<Database>({ 100 29 dialect: new D1Dialect({ database: platform.env.DB }), 101 30 }); 102 - console.log('[DB] Using Cloudflare D1, hasSelectFrom:', typeof d1db.selectFrom); 103 - return d1db; 104 31 } 105 32 106 - // Local development: Use better-sqlite3 107 - console.log('[DB] Using local SQLite database for development'); 108 - const db = initLocalDb(); 109 - console.log('[DB] getDb returning:', typeof db, 'hasSelectFrom:', typeof db.selectFrom, 'keys:', Object.keys(db).slice(0, 5)); 110 - return db; 33 + // No D1 configured 34 + throw new Error( 35 + 'Database not configured. In Cloudflare, set up D1 and bind it as "DB". ' + 36 + 'For local development, use `wrangler pages dev` with D1 bindings.' 37 + ); 111 38 }
+6
src/routes/auth/callback/+server.ts
··· 23 23 } 24 24 25 25 console.error('OAuth callback error:', err); 26 + if (err instanceof Error) { 27 + console.error('Error name:', err.name); 28 + console.error('Error message:', err.message); 29 + console.error('Error stack:', err.stack); 30 + if ('cause' in err) console.error('Error cause:', err.cause); 31 + } 26 32 27 33 // Handle specific OAuth callback errors 28 34 if (err instanceof OAuthCallbackError) {
+2 -2
src/routes/jwks.json/+server.ts
··· 2 2 import type { RequestHandler } from './$types'; 3 3 import { getOAuthClient } from '$lib/server/auth'; 4 4 5 - export const GET: RequestHandler = async ({ setHeaders }) => { 6 - const oauth = await getOAuthClient(); 5 + export const GET: RequestHandler = async ({ setHeaders, platform }) => { 6 + const oauth = await getOAuthClient(platform); 7 7 8 8 // Add CORS headers for OAuth server to fetch JWKS 9 9 setHeaders({
+2 -2
src/routes/oauth-client-metadata.json/+server.ts
··· 2 2 import type { RequestHandler } from './$types'; 3 3 import { getOAuthClient } from '$lib/server/auth'; 4 4 5 - export const GET: RequestHandler = async ({ setHeaders }) => { 6 - const oauth = await getOAuthClient(); 5 + export const GET: RequestHandler = async ({ setHeaders, platform }) => { 6 + const oauth = await getOAuthClient(platform); 7 7 8 8 // Add CORS headers for OAuth server to fetch metadata 9 9 setHeaders({
+8 -7
wrangler.toml
··· 1 1 name = "atprotogo" 2 2 pages_build_output_dir = ".svelte-kit/cloudflare" 3 3 compatibility_date = "2024-01-01" 4 + compatibility_flags = ["nodejs_compat"] 4 5 5 6 [vars] 6 - PUBLIC_BASE_URL = "https://atprotogo.pages.dev" 7 + PUBLIC_BASE_URL = "https://go.sky.boo" 8 + 9 + [[d1_databases]] 10 + binding = "DB" 11 + database_name = "atprotogo-db" 12 + database_id = "7509f118-4693-4dce-9d58-fdb8c768ae9c" 7 13 8 - # Note: Add these bindings in Cloudflare dashboard or after creating resources: 9 - # [[d1_databases]] 10 - # binding = "DB" 11 - # database_name = "atprotogo-db" 12 - # database_id = "YOUR_DATABASE_ID_FROM_WRANGLER_D1_CREATE" 13 - # 14 + # Optional: KV namespaces for OAuth session persistence 14 15 # [[kv_namespaces]] 15 16 # binding = "SESSIONS_KV" 16 17 # id = "YOUR_SESSIONS_KV_ID"