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.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add OAuth scope for reactions and cosmetic improvements

OAuth:
- Add repo:boo.sky.go.reaction?action=create to OAuth scopes

UI improvements:
- Move share button from header to game info section
- Add "Copy Invite" button for waiting games you created
- Change share hashtag to use game title (#CloudGo_GameName)
- Show "Loading..." instead of truncated DID for reaction authors
- Show "(you)" label for user's own reactions
- Resolve handles for newly posted reactions

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

+133 -49
+32 -27
src/lib/server/auth.ts
··· 1 - import { type RequestEvent } from '@sveltejs/kit'; 2 - import { SESSION_SECRET, PRIVATE_KEY_JWK } from '$env/static/private'; 1 + import { type RequestEvent } from "@sveltejs/kit"; 2 + import { SESSION_SECRET, PRIVATE_KEY_JWK } from "$env/static/private"; 3 3 import { 4 4 OAuthClient, 5 5 MemoryStore, 6 6 importJwkKey, 7 7 type StoredState, 8 - type OAuthSession 9 - } from '@atcute/oauth-node-client'; 8 + type OAuthSession, 9 + } from "@atcute/oauth-node-client"; 10 10 import { 11 11 CompositeDidDocumentResolver, 12 12 CompositeHandleResolver, 13 13 LocalActorResolver, 14 14 PlcDidDocumentResolver, 15 15 WebDidDocumentResolver, 16 - WellKnownHandleResolver 17 - } from '@atcute/identity-resolver'; 18 - import { NodeDnsHandleResolver } from '@atcute/identity-resolver-node'; 19 - import type { Did } from '@atcute/lexicons/syntax'; 20 - import { Client } from '@atcute/client'; 16 + WellKnownHandleResolver, 17 + } from "@atcute/identity-resolver"; 18 + import { NodeDnsHandleResolver } from "@atcute/identity-resolver-node"; 19 + import type { Did } from "@atcute/lexicons/syntax"; 20 + import { Client } from "@atcute/client"; 21 21 22 22 let oauthClient: OAuthClient | null = null; 23 23 ··· 30 30 } 31 31 32 32 if (!PRIVATE_KEY_JWK) { 33 - throw new Error('PRIVATE_KEY_JWK environment variable is required for OAuth'); 33 + throw new Error( 34 + "PRIVATE_KEY_JWK environment variable is required for OAuth", 35 + ); 34 36 } 35 37 36 38 const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL; 37 39 38 40 if (!PUBLIC_BASE_URL) { 39 - throw new Error('PUBLIC_BASE_URL environment variable is required for OAuth. Check your .env file.'); 41 + throw new Error( 42 + "PUBLIC_BASE_URL environment variable is required for OAuth. Check your .env file.", 43 + ); 40 44 } 41 45 42 46 const publicUrl = new URL(PUBLIC_BASE_URL); 43 47 44 48 oauthClient = new OAuthClient({ 45 49 metadata: { 46 - client_id: new URL('/oauth-client-metadata.json', publicUrl).href, 47 - client_name: 'ATProtoGo - Decentralized Go Game', 48 - redirect_uris: [new URL('/auth/callback', publicUrl).href], 49 - scope: 'atproto 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', 50 - jwks_uri: new URL('/jwks.json', publicUrl).href, 50 + client_id: new URL("/oauth-client-metadata.json", publicUrl).href, 51 + client_name: "Cloud Go", 52 + redirect_uris: [new URL("/auth/callback", publicUrl).href], 53 + scope: 54 + "atproto 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", 55 + jwks_uri: new URL("/jwks.json", publicUrl).href, 51 56 }, 52 57 53 58 keyset: await Promise.all([importJwkKey(PRIVATE_KEY_JWK)]), ··· 87 92 handle?: string; 88 93 } 89 94 90 - const COOKIE_NAME = 'go_session'; 95 + const COOKIE_NAME = "go_session"; 91 96 92 97 export async function getSession(event: RequestEvent): Promise<Session | null> { 93 98 const did = event.cookies.get(COOKIE_NAME); ··· 95 100 96 101 try { 97 102 const oauth = await getOAuthClient(); 98 - const oauthSession = await oauth.restore(did as Did, { refresh: 'auto' }); 103 + const oauthSession = await oauth.restore(did as Did, { refresh: "auto" }); 99 104 100 105 // Return session with DID and optionally fetch handle if needed 101 106 return { ··· 103 108 }; 104 109 } catch (error) { 105 110 // If session restore fails, clear the cookie 106 - event.cookies.delete(COOKIE_NAME, { path: '/' }); 111 + event.cookies.delete(COOKIE_NAME, { path: "/" }); 107 112 return null; 108 113 } 109 114 } 110 115 111 116 export async function setSession(event: RequestEvent, session: Session) { 112 117 const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL; 113 - const secure = PUBLIC_BASE_URL?.startsWith('https:') ?? false; 118 + const secure = PUBLIC_BASE_URL?.startsWith("https:") ?? false; 114 119 115 120 event.cookies.set(COOKIE_NAME, session.did, { 116 - path: '/', 121 + path: "/", 117 122 httpOnly: true, 118 - sameSite: 'lax', 123 + sameSite: "lax", 119 124 secure, 120 125 maxAge: 60 * 60 * 24 * 7, // 7 days 121 126 }); ··· 123 128 124 129 export async function clearSession(event: RequestEvent) { 125 130 const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL; 126 - const secure = PUBLIC_BASE_URL?.startsWith('https:') ?? false; 131 + const secure = PUBLIC_BASE_URL?.startsWith("https:") ?? false; 127 132 128 133 event.cookies.delete(COOKIE_NAME, { 129 - path: '/', 134 + path: "/", 130 135 httpOnly: true, 131 - sameSite: 'lax', 136 + sameSite: "lax", 132 137 secure, 133 138 }); 134 139 } ··· 139 144 140 145 try { 141 146 const oauth = await getOAuthClient(); 142 - const oauthSession = await oauth.restore(did as Did, { refresh: 'auto' }); 147 + const oauthSession = await oauth.restore(did as Did, { refresh: "auto" }); 143 148 144 149 // Create a client using the OAuth session as the handler 145 150 const client = new Client({ handler: oauthSession }); 146 151 return client; 147 152 } catch (error) { 148 - console.error('Failed to get agent:', error); 153 + console.error("Failed to get agent:", error); 149 154 return null; 150 155 } 151 156 }
+23 -16
src/routes/auth/login/+server.ts
··· 1 - import { json, error } from '@sveltejs/kit'; 2 - import type { RequestHandler } from './$types'; 3 - import { getOAuthClient } from '$lib/server/auth'; 4 - import { isActorIdentifier } from '@atcute/lexicons/syntax'; 5 - import type { AuthorizeTarget } from '@atcute/oauth-node-client'; 1 + import { json, error } from "@sveltejs/kit"; 2 + import type { RequestHandler } from "./$types"; 3 + import { getOAuthClient } from "$lib/server/auth"; 4 + import { isActorIdentifier } from "@atcute/lexicons/syntax"; 5 + import type { AuthorizeTarget } from "@atcute/oauth-node-client"; 6 6 7 7 const isProbablyUrl = (input: string): boolean => { 8 8 try { 9 9 const url = new URL(input); 10 - return url.protocol === 'https:' || url.protocol === 'http:'; 10 + return url.protocol === "https:" || url.protocol === "http:"; 11 11 } catch { 12 12 return false; 13 13 } ··· 17 17 try { 18 18 const { handle } = await request.json(); 19 19 20 - if (!handle || typeof handle !== 'string') { 21 - throw error(400, 'Handle is required'); 20 + if (!handle || typeof handle !== "string") { 21 + throw error(400, "Handle is required"); 22 22 } 23 23 24 24 const identifier = handle.trim(); 25 25 if (!identifier) { 26 - throw error(400, 'Handle cannot be empty'); 26 + throw error(400, "Handle cannot be empty"); 27 27 } 28 28 29 29 // Determine the target type based on the identifier format 30 30 let target: AuthorizeTarget; 31 31 if (isProbablyUrl(identifier)) { 32 32 // If it's a URL, treat it as a PDS service URL 33 - target = { type: 'pds', serviceUrl: identifier }; 33 + target = { type: "pds", serviceUrl: identifier }; 34 34 } else if (isActorIdentifier(identifier)) { 35 35 // If it's a valid actor identifier (handle or DID), use account type 36 - target = { type: 'account', identifier }; 36 + target = { type: "account", identifier }; 37 37 } else { 38 - throw error(400, 'Invalid identifier. Expected a handle (e.g. alice.bsky.social) or DID (e.g. did:plc:...)'); 38 + throw error( 39 + 400, 40 + "Invalid identifier. Expected a handle (e.g. alice.bsky.social) or DID (e.g. did:plc:...)", 41 + ); 39 42 } 40 43 41 44 const oauth = await getOAuthClient(); 42 45 const { url } = await oauth.authorize({ 43 46 target, 44 - scope: 'atproto 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', 47 + scope: 48 + "atproto 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", 45 49 state: { startedAt: Date.now() }, 46 50 }); 47 51 48 - console.log('Authorization URL:', url.href); 52 + console.log("Authorization URL:", url.href); 49 53 50 54 // Return the URL to the client for navigation (not redirect) 51 55 // This ensures proper browser navigation with sec-fetch-site headers 52 56 return json({ authorizationUrl: url.href }); 53 57 } catch (err) { 54 - console.error('Login error:', err); 55 - throw error(500, err instanceof Error ? err.message : 'Failed to initiate OAuth flow'); 58 + console.error("Login error:", err); 59 + throw error( 60 + 500, 61 + err instanceof Error ? err.message : "Failed to initiate OAuth flow", 62 + ); 56 63 } 57 64 };
+78 -6
src/routes/game/[id]/+page.svelte
··· 341 341 const currentReactions = reactions.get(moveUri) || []; 342 342 reactions = new Map(reactions).set(moveUri, [newReaction, ...currentReactions]); 343 343 344 + // Resolve handle for the author if not already known 345 + if (!reactionHandles[data.session.did]) { 346 + resolveDidToHandle(data.session.did).then((h) => { 347 + reactionHandles = { ...reactionHandles, [data.session.did]: h }; 348 + }); 349 + } 350 + 344 351 // Clear form 345 352 reactionText = ''; 346 353 reactionEmoji = ''; ··· 498 505 text += ` between ${mentions.join(' and ')}`; 499 506 } 500 507 501 - text += `! #CloudGo_${data.gameRkey.slice(0, 8)}`; 508 + // Use game title for hashtag, removing spaces and special chars 509 + const hashtagName = gameTitle(data.gameRkey).replace(/[^a-zA-Z0-9]/g, ''); 510 + text += `! #CloudGo_${hashtagName}`; 502 511 text += `\n\n${gameUrl}`; 503 512 504 513 return `https://bsky.app/intent/compose?text=${encodeURIComponent(text)}`; 514 + } 515 + 516 + async function copyInviteLink() { 517 + const gameUrl = `${window.location.origin}/game/${data.gameRkey}`; 518 + try { 519 + await navigator.clipboard.writeText(gameUrl); 520 + alert('Invite link copied to clipboard!'); 521 + } catch { 522 + // Fallback for older browsers 523 + prompt('Copy this link:', gameUrl); 524 + } 505 525 } 506 526 </script> 507 527 ··· 536 556 <span class="offline-indicator" title="Real-time updates unavailable. Refresh page to see new moves.">⚠️ Offline Mode</span> 537 557 {/if} 538 558 {/if} 539 - <a href={getShareUrl()} target="_blank" rel="noopener noreferrer" class="share-button"> 540 - 🦋 Share on Bluesky 541 - </a> 542 559 <a href="/" class="back-link">← Back to games</a> 543 560 </div> 544 561 </header> ··· 551 568 <div class="game-info"> 552 569 <div class="info-card cloud-card"> 553 570 <h3>Game Info</h3> 554 - <p><strong>Status:</strong> <span class="status-{gameStatus}">{gameStatus}</span></p> 571 + <p> 572 + <strong>Status:</strong> <span class="status-{gameStatus}">{gameStatus}</span> 573 + {#if gameStatus === 'waiting' && data.session && data.session.did === gamePlayerOne} 574 + <button class="copy-invite-btn" onclick={copyInviteLink}> 575 + Copy Invite 576 + </button> 577 + {/if} 578 + </p> 555 579 <p><strong>Board:</strong> {gameBoardSize}x{gameBoardSize}</p> 556 580 <p><strong>Moves:</strong> {moves.length}</p> 581 + <a href={getShareUrl()} target="_blank" rel="noopener noreferrer" class="share-button-small"> 582 + 🦋 Share on Bluesky 583 + </a> 557 584 </div> 558 585 559 586 <div class="info-card cloud-card"> ··· 869 896 rel="noopener noreferrer" 870 897 class="reaction-author" 871 898 > 872 - {reactionHandles[reaction.author] || reaction.author.slice(0, 20)} 899 + {reactionHandles[reaction.author] || 'Loading...'} 873 900 </a> 901 + {#if data.session && reaction.author === data.session.did} 902 + <span class="you-label">(you)</span> 903 + {/if} 874 904 {#if reaction.stars} 875 905 <span class="reaction-stars"> 876 906 {'★'.repeat(reaction.stars)}{'☆'.repeat(5 - reaction.stars)} ··· 1004 1034 .share-button svg { 1005 1035 width: 16px; 1006 1036 height: 16px; 1037 + } 1038 + 1039 + .share-button-small { 1040 + display: inline-flex; 1041 + align-items: center; 1042 + gap: 0.375rem; 1043 + padding: 0.375rem 0.75rem; 1044 + background: linear-gradient(135deg, #0085ff 0%, #0066cc 100%); 1045 + color: white; 1046 + border: none; 1047 + border-radius: 0.375rem; 1048 + font-size: 0.75rem; 1049 + font-weight: 600; 1050 + cursor: pointer; 1051 + transition: all 0.2s; 1052 + text-decoration: none; 1053 + margin-top: 0.75rem; 1054 + } 1055 + 1056 + .share-button-small:hover { 1057 + transform: translateY(-1px); 1058 + box-shadow: 0 2px 8px rgba(0, 133, 255, 0.3); 1059 + } 1060 + 1061 + .copy-invite-btn { 1062 + display: inline-flex; 1063 + align-items: center; 1064 + padding: 0.25rem 0.5rem; 1065 + margin-left: 0.5rem; 1066 + background: var(--sky-apricot-light); 1067 + color: var(--sky-apricot-dark); 1068 + border: 1px solid var(--sky-apricot); 1069 + border-radius: 0.25rem; 1070 + font-size: 0.7rem; 1071 + font-weight: 600; 1072 + cursor: pointer; 1073 + transition: all 0.2s; 1074 + } 1075 + 1076 + .copy-invite-btn:hover { 1077 + background: var(--sky-apricot); 1078 + color: white; 1007 1079 } 1008 1080 1009 1081 .live-indicator {