Chess on the ATmosphere checkmate.blue
chess
18
fork

Configure Feed

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

Narrow OAuth scope and switch to Bluesky intent/compose

Replace API-based Bluesky posting with bsky.app/intent/compose links,
removing the need for app.bsky.feed.post write access. Drop the blanket
transition:generic OAuth scope in favor of a custom permission set
(blue.checkmate.authFullAccess) that grants access only to game and
challenge records. The consent screen now shows a single friendly label
instead of broad scary permissions.

- Remove buildFacets, postToBluesky and all facet/embed machinery
- Add openBlueskyCompose() using bsky.app/intent/compose?text=
- Add blue.checkmate.authFullAccess permission set Lexicon
- Publish schema to @checkmate.blue PDS via com.atproto.lexicon.schema
- DNS TXT record at _lexicon.checkmate.blue points to the publishing DID
- Deduplicate SCOPE const between oauth.ts and auth.svelte.ts

+94 -275
+18
lexicons/blue.checkmate.authFullAccess.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "blue.checkmate.authFullAccess", 4 + "defs": { 5 + "main": { 6 + "type": "permission-set", 7 + "title": "Full checkmate.blue Access", 8 + "detail": "Create and manage chess games and challenges on checkmate.blue.", 9 + "permissions": [ 10 + { 11 + "type": "permission", 12 + "resource": "repo", 13 + "collection": ["blue.checkmate.game", "blue.checkmate.challenge"] 14 + } 15 + ] 16 + } 17 + } 18 + }
+6 -97
src/lib/bluesky.ts
··· 1 - import type { Agent } from '@atproto/api'; 2 - 3 - interface Facet { 4 - index: { byteStart: number; byteEnd: number }; 5 - features: Array< 6 - | { $type: 'app.bsky.richtext.facet#mention'; did: string } 7 - | { $type: 'app.bsky.richtext.facet#link'; uri: string } 8 - >; 9 - } 10 - 11 - const encoder = new TextEncoder(); 12 - 13 - /** Compute the byte offset of a substring within a string (UTF-8). */ 14 - function byteOffset(text: string, charIndex: number): number { 15 - return encoder.encode(text.slice(0, charIndex)).byteLength; 16 - } 17 - 18 - /** Build facets for @mentions and URLs in post text. */ 19 - export function buildFacets( 20 - text: string, 21 - knownHandles: Map<string, string>, 22 - ): Facet[] { 23 - const facets: Facet[] = []; 24 - 25 - // Detect @mentions 26 - const mentionRe = /(^|[\s(])@([a-zA-Z0-9.-]+(?:\.[a-zA-Z]{2,}))/g; 27 - let match; 28 - while ((match = mentionRe.exec(text)) !== null) { 29 - const handle = match[2]; 30 - const did = knownHandles.get(handle); 31 - if (!did) continue; 32 - 33 - const mentionStart = match.index + match[1].length; 34 - const mentionText = `@${handle}`; 35 - facets.push({ 36 - index: { 37 - byteStart: byteOffset(text, mentionStart), 38 - byteEnd: byteOffset(text, mentionStart + mentionText.length), 39 - }, 40 - features: [{ $type: 'app.bsky.richtext.facet#mention', did }], 41 - }); 42 - } 43 - 44 - // Detect URLs 45 - const urlRe = /https?:\/\/[^\s)]+/g; 46 - while ((match = urlRe.exec(text)) !== null) { 47 - facets.push({ 48 - index: { 49 - byteStart: byteOffset(text, match.index), 50 - byteEnd: byteOffset(text, match.index + match[0].length), 51 - }, 52 - features: [{ $type: 'app.bsky.richtext.facet#link', uri: match[0] }], 53 - }); 54 - } 55 - 56 - return facets; 57 - } 58 - 59 - /** Post a Bluesky post with auto-detected facets for mentions and links. */ 60 - export async function postToBluesky( 61 - agent: Agent, 62 - text: string, 63 - knownHandles: Map<string, string>, 64 - embedUrl?: string, 65 - embedTitle?: string, 66 - embedDescription?: string, 67 - ): Promise<{ uri: string; cid: string }> { 68 - const facets = buildFacets(text, knownHandles); 69 - 70 - const record: Record<string, unknown> = { 71 - $type: 'app.bsky.feed.post', 72 - text, 73 - facets, 74 - createdAt: new Date().toISOString(), 75 - }; 76 - 77 - if (embedUrl) { 78 - record.embed = { 79 - $type: 'app.bsky.embed.external', 80 - external: { 81 - uri: embedUrl, 82 - title: embedTitle ?? 'checkmate.blue', 83 - description: embedDescription ?? 'Chess on the Atmosphere', 84 - }, 85 - }; 86 - } 87 - 88 - const response = await agent.com.atproto.repo.createRecord({ 89 - repo: agent.assertDid, 90 - collection: 'app.bsky.feed.post', 91 - record, 92 - }); 93 - 94 - return { uri: response.data.uri, cid: response.data.cid }; 95 - } 96 - 97 1 /** Compose the default text for a challenge post. */ 98 2 export function composeChallengePost( 99 3 opponentHandle: string, ··· 136 40 const label = drawReasons[result.reason] ?? 'Draw'; 137 41 text = `${label} with ${opponent} on checkmate.blue${moveSuffix}`; 138 42 } else { 139 - // Loss 140 43 if (result.reason === 'checkmate') { 141 44 text = `Got checkmated by ${opponent} on checkmate.blue -- good game!${moveSuffix}`; 142 45 } else { ··· 154 57 for (const _ of segmenter.segment(text)) count++; 155 58 return count; 156 59 } 60 + 61 + /** Open the Bluesky compose page with pre-populated text. */ 62 + export function openBlueskyCompose(text: string): void { 63 + const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(text)}`; 64 + window.open(url, '_blank'); 65 + }
+1 -1
src/lib/oauth.ts
··· 1 1 import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; 2 2 import { buildAtprotoLoopbackClientMetadata } from '@atproto/oauth-types'; 3 3 4 - const SCOPE = 'atproto transition:generic'; 4 + const SCOPE = 'atproto include:blue.checkmate.authFullAccess'; 5 5 6 6 const isProd = typeof window !== 'undefined' && window.location.hostname === 'checkmate.blue'; 7 7
+1 -1
src/lib/stores/auth.svelte.ts
··· 1 1 import { Agent } from '@atproto/api'; 2 2 import type { BrowserOAuthClient } from '@atproto/oauth-client-browser'; 3 3 4 - const SCOPE = 'atproto transition:generic'; 4 + import { SCOPE } from '$lib/oauth'; 5 5 6 6 let agent: Agent | null = $state(null); 7 7 let did: string | undefined = $state(undefined);
+13 -69
src/routes/game/[did]/[rkey]/+page.svelte
··· 19 19 import type { GameRecord } from '$lib/types'; 20 20 import { JetstreamConnection } from '$lib/jetstream'; 21 21 import { resolveIdentity } from '$lib/microcosm'; 22 - import { composeChallengePost, composeGameResultPost, postToBluesky, graphemeCount } from '$lib/bluesky'; 22 + import { composeChallengePost, composeGameResultPost, openBlueskyCompose, graphemeCount } from '$lib/bluesky'; 23 23 import LoginButton from '$lib/components/LoginButton.svelte'; 24 24 import type { PieceSymbol } from 'chess.js'; 25 25 ··· 39 39 let rematchCreating = $state(false); 40 40 let analyzingOnLichess = $state(false); 41 41 let shareText = $state(''); 42 - let sharing = $state(false); 43 42 let shared = $state(false); 44 - let shareError = $state(''); 45 43 let dismissShare = $state(false); 46 44 let rematchOffer: { did: string; rkey: string } | null = $state(null); 47 45 let rematchDismissed = $state(false); ··· 94 92 shareText = ''; 95 93 shared = false; 96 94 dismissShare = false; 97 - shareError = ''; 98 95 rematchCreating = false; 99 96 rematchOffer = null; 100 97 rematchDismissed = false; ··· 649 646 } 650 647 }); 651 648 652 - async function shareResult() { 653 - if (!auth.agent || !shareText.trim()) return; 654 - sharing = true; 655 - shareError = ''; 656 - 657 - try { 658 - const opDid = game.myColor === 'white' ? game.blackDid : game.whiteDid; 659 - const handles = new Map<string, string>(); 660 - if (opDid && opponentHandle) { 661 - handles.set(opponentHandle, opDid); 662 - } 663 - 664 - await postToBluesky( 665 - auth.agent, 666 - shareText, 667 - handles, 668 - gameUrl(), 669 - `${game.whiteHandle ?? 'White'} vs ${game.blackHandle ?? 'Black'}`, 670 - `${game.result!.result} by ${game.result!.reason}`, 671 - ); 672 - shared = true; 673 - } catch (e) { 674 - shareError = e instanceof Error ? e.message : 'Failed to post'; 675 - } finally { 676 - sharing = false; 677 - } 649 + function shareResult() { 650 + if (!shareText.trim()) return; 651 + openBlueskyCompose(shareText); 652 + shared = true; 678 653 } 679 654 680 655 function flipBoard() { ··· 682 657 } 683 658 684 659 let copied = $state(false); 685 - let posting = $state(false); 686 660 let posted = $state(false); 687 - let postError = $state(''); 688 661 let dmCopied = $state(false); 689 662 690 663 function gameUrl(): string { ··· 704 677 window.open('https://bsky.app/messages', '_blank'); 705 678 } 706 679 707 - async function postChallengeToBluesky() { 708 - if (!auth.agent || !opponentHandle || posted) return; 709 - posting = true; 710 - postError = ''; 711 - 712 - const opponentDid = game.myColor === 'white' ? game.blackDid : game.whiteDid; 680 + function postChallengeToBluesky() { 681 + if (!opponentHandle || posted) return; 713 682 const text = composeChallengePost(opponentHandle, gameUrl()); 714 - const handles = new Map<string, string>(); 715 - if (opponentDid && opponentHandle) { 716 - handles.set(opponentHandle, opponentDid); 717 - } 718 - 719 - try { 720 - await postToBluesky( 721 - auth.agent, 722 - text, 723 - handles, 724 - gameUrl(), 725 - 'Chess Challenge on checkmate.blue', 726 - `${auth.handle} wants to play chess!`, 727 - ); 728 - posted = true; 729 - } catch (e) { 730 - postError = e instanceof Error ? e.message : 'Failed to post'; 731 - } finally { 732 - posting = false; 733 - } 683 + openBlueskyCompose(text); 684 + posted = true; 734 685 } 735 686 736 687 const isWaiting = $derived(game.status === 'waiting'); ··· 809 760 {:else} 810 761 <button 811 762 onclick={postChallengeToBluesky} 812 - disabled={posting} 813 - class="rounded-lg border border-accent-blue px-4 py-2 text-sm font-semibold text-accent-blue transition-colors hover:bg-accent-blue hover:text-white disabled:opacity-50" 763 + class="rounded-lg border border-accent-blue px-4 py-2 text-sm font-semibold text-accent-blue transition-colors hover:bg-accent-blue hover:text-white" 814 764 > 815 - {posting ? 'Posting...' : 'Post to Bluesky'} 765 + Post to Bluesky 816 766 </button> 817 767 {/if} 818 768 <button ··· 822 772 {dmCopied ? 'Link copied! Paste in DM' : 'Send via DM'} 823 773 </button> 824 774 </div> 825 - {#if postError} 826 - <p class="text-sm text-danger">{postError}</p> 827 - {/if} 828 775 </div> 829 776 {/if} 830 777 </div> ··· 982 929 <div class="flex gap-2"> 983 930 <button 984 931 onclick={shareResult} 985 - disabled={sharing || overLimit || !shareText.trim()} 932 + disabled={overLimit || !shareText.trim()} 986 933 class="rounded-lg bg-accent-blue px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-accent-blue-hover disabled:opacity-50" 987 934 > 988 - {sharing ? 'Posting...' : 'Share to Bluesky'} 935 + Share to Bluesky 989 936 </button> 990 937 <button 991 938 onclick={() => dismissShare = true} ··· 996 943 </div> 997 944 <span class="text-xs {overLimit ? 'text-danger' : 'text-text-secondary'}">{count}/300</span> 998 945 </div> 999 - {#if shareError} 1000 - <p class="mt-2 text-sm text-danger">{shareError}</p> 1001 - {/if} 1002 946 </div> 1003 947 {/if} 1004 948 {/if}
+1 -1
src/routes/oauth/client-metadata.json/+server.ts
··· 8 8 client_name: 'checkmate.blue', 9 9 client_uri: 'https://checkmate.blue', 10 10 redirect_uris: ['https://checkmate.blue/oauth/callback'], 11 - scope: 'atproto transition:generic', 11 + scope: 'atproto include:blue.checkmate.authFullAccess', 12 12 grant_types: ['authorization_code', 'refresh_token'], 13 13 response_types: ['code'], 14 14 token_endpoint_auth_method: 'none',
+54 -106
tests/lib/bluesky.test.ts
··· 1 1 import { describe, it, expect } from 'vitest'; 2 - import { buildFacets, composeChallengePost } from '$lib/bluesky'; 3 - 4 - const encoder = new TextEncoder(); 5 - 6 - describe('buildFacets', () => { 7 - it('detects a mention with a known handle', () => { 8 - const text = 'Hello @alice.bsky.social!'; 9 - const handles = new Map([['alice.bsky.social', 'did:plc:alice']]); 10 - const facets = buildFacets(text, handles); 11 - 12 - expect(facets).toHaveLength(1); 13 - expect(facets[0].features[0]).toEqual({ 14 - $type: 'app.bsky.richtext.facet#mention', 15 - did: 'did:plc:alice', 16 - }); 17 - 18 - const byteStart = facets[0].index.byteStart; 19 - const byteEnd = facets[0].index.byteEnd; 20 - const slice = encoder.encode(text).slice(byteStart, byteEnd); 21 - expect(new TextDecoder().decode(slice)).toBe('@alice.bsky.social'); 22 - }); 23 - 24 - it('skips mentions for unknown handles', () => { 25 - const text = 'Hello @unknown.handle.com!'; 26 - const handles = new Map<string, string>(); 27 - const facets = buildFacets(text, handles); 2 + import { composeChallengePost, composeGameResultPost, graphemeCount } from '$lib/bluesky'; 28 3 29 - const mentions = facets.filter(f => 30 - f.features.some(feat => '$type' in feat && feat.$type === 'app.bsky.richtext.facet#mention') 4 + describe('composeChallengePost', () => { 5 + it('includes opponent handle and game URL', () => { 6 + const text = composeChallengePost( 7 + 'alice.bsky.social', 8 + 'https://checkmate.blue/game/did:plc:white/abc123', 31 9 ); 32 - expect(mentions).toHaveLength(0); 33 - }); 34 - 35 - it('detects URLs', () => { 36 - const text = 'Check out https://checkmate.blue/game/did/rkey for the game'; 37 - const facets = buildFacets(text, new Map()); 38 - 39 - expect(facets).toHaveLength(1); 40 - expect(facets[0].features[0]).toEqual({ 41 - $type: 'app.bsky.richtext.facet#link', 42 - uri: 'https://checkmate.blue/game/did/rkey', 43 - }); 44 - 45 - const byteStart = facets[0].index.byteStart; 46 - const byteEnd = facets[0].index.byteEnd; 47 - const slice = encoder.encode(text).slice(byteStart, byteEnd); 48 - expect(new TextDecoder().decode(slice)).toBe('https://checkmate.blue/game/did/rkey'); 10 + expect(text).toContain('@alice.bsky.social'); 11 + expect(text).toContain('https://checkmate.blue/game/did:plc:white/abc123'); 49 12 }); 50 - 51 - it('detects both mention and URL in the same text', () => { 52 - const text = "I'm challenging @bob.test to chess!\n\nhttps://checkmate.blue/game/did/rkey"; 53 - const handles = new Map([['bob.test', 'did:plc:bob']]); 54 - const facets = buildFacets(text, handles); 55 - 56 - expect(facets).toHaveLength(2); 13 + }); 57 14 58 - const mention = facets.find(f => 59 - f.features.some(feat => '$type' in feat && feat.$type === 'app.bsky.richtext.facet#mention') 60 - ); 61 - const link = facets.find(f => 62 - f.features.some(feat => '$type' in feat && feat.$type === 'app.bsky.richtext.facet#link') 15 + describe('composeGameResultPost', () => { 16 + it('composes a win by checkmate message', () => { 17 + const text = composeGameResultPost( 18 + 'white', 19 + { result: '1-0', reason: 'checkmate' }, 20 + 'bob.test', 21 + 34, 22 + 'https://checkmate.blue/game/did/rkey', 63 23 ); 64 - 65 - expect(mention).toBeDefined(); 66 - expect(link).toBeDefined(); 24 + expect(text).toContain('Checkmate'); 25 + expect(text).toContain('@bob.test'); 26 + expect(text).toContain('34 moves'); 67 27 }); 68 28 69 - it('computes correct byte offsets for unicode text before the mention', () => { 70 - // Emoji takes 4 bytes in UTF-8 71 - const text = '\u{1F600} Hello @alice.bsky.social!'; 72 - const handles = new Map([['alice.bsky.social', 'did:plc:alice']]); 73 - const facets = buildFacets(text, handles); 74 - 75 - expect(facets).toHaveLength(1); 76 - 77 - const byteStart = facets[0].index.byteStart; 78 - const byteEnd = facets[0].index.byteEnd; 79 - const bytes = encoder.encode(text); 80 - const slice = bytes.slice(byteStart, byteEnd); 81 - expect(new TextDecoder().decode(slice)).toBe('@alice.bsky.social'); 29 + it('composes a loss message', () => { 30 + const text = composeGameResultPost( 31 + 'white', 32 + { result: '0-1', reason: 'checkmate' }, 33 + 'bob.test', 34 + 20, 35 + 'https://checkmate.blue/game/did/rkey', 36 + ); 37 + expect(text).toContain('checkmated by'); 38 + expect(text).toContain('@bob.test'); 82 39 }); 83 40 84 - it('handles handles with hyphens and numbers', () => { 85 - const text = 'Playing @test-user123.bsky.social today'; 86 - const handles = new Map([['test-user123.bsky.social', 'did:plc:test123']]); 87 - const facets = buildFacets(text, handles); 88 - 89 - expect(facets).toHaveLength(1); 90 - expect(facets[0].features[0]).toEqual({ 91 - $type: 'app.bsky.richtext.facet#mention', 92 - did: 'did:plc:test123', 93 - }); 41 + it('composes a draw message', () => { 42 + const text = composeGameResultPost( 43 + 'white', 44 + { result: '1/2-1/2', reason: 'stalemate' }, 45 + 'bob.test', 46 + 50, 47 + 'https://checkmate.blue/game/did/rkey', 48 + ); 49 + expect(text).toContain('Stalemate'); 94 50 }); 95 51 96 - it('detects mention at start of text', () => { 97 - const text = '@alice.bsky.social check this out'; 98 - const handles = new Map([['alice.bsky.social', 'did:plc:alice']]); 99 - const facets = buildFacets(text, handles); 100 - 101 - expect(facets).toHaveLength(1); 102 - const byteStart = facets[0].index.byteStart; 103 - const byteEnd = facets[0].index.byteEnd; 104 - const slice = encoder.encode(text).slice(byteStart, byteEnd); 105 - expect(new TextDecoder().decode(slice)).toBe('@alice.bsky.social'); 52 + it('omits move count for short games', () => { 53 + const text = composeGameResultPost( 54 + 'white', 55 + { result: '1-0', reason: 'resignation' }, 56 + 'bob.test', 57 + 8, 58 + 'https://checkmate.blue/game/did/rkey', 59 + ); 60 + expect(text).not.toContain('moves'); 106 61 }); 62 + }); 107 63 108 - it('returns empty array for text with no mentions or URLs', () => { 109 - const text = 'Just a plain message with no links'; 110 - const facets = buildFacets(text, new Map()); 111 - expect(facets).toHaveLength(0); 64 + describe('graphemeCount', () => { 65 + it('counts ASCII characters', () => { 66 + expect(graphemeCount('hello')).toBe(5); 112 67 }); 113 - }); 114 68 115 - describe('composeChallengePost', () => { 116 - it('includes opponent handle and game URL', () => { 117 - const text = composeChallengePost( 118 - 'alice.bsky.social', 119 - 'https://checkmate.blue/game/did:plc:white/abc123', 120 - ); 121 - expect(text).toContain('@alice.bsky.social'); 122 - expect(text).toContain('https://checkmate.blue/game/did:plc:white/abc123'); 69 + it('counts emoji as single graphemes', () => { 70 + expect(graphemeCount('hi \u{1F600} there')).toBe(10); 123 71 }); 124 72 });