From facebd26e9a73de905f80a7aa5b744f213cb02fd Mon Sep 17 00:00:00 2001 From: Scott Hadfield Date: Mon, 13 Apr 2026 12:27:24 -0700 Subject: [PATCH] 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 --- lexicons/blue.checkmate.authFullAccess.json | 18 ++ src/lib/bluesky.ts | 103 +---------- src/lib/oauth.ts | 2 +- src/lib/stores/auth.svelte.ts | 2 +- src/routes/game/[did]/[rkey]/+page.svelte | 82 ++------- .../oauth/client-metadata.json/+server.ts | 2 +- tests/lib/bluesky.test.ts | 160 ++++++------------ 7 files changed, 94 insertions(+), 275 deletions(-) create mode 100644 lexicons/blue.checkmate.authFullAccess.json diff --git a/lexicons/blue.checkmate.authFullAccess.json b/lexicons/blue.checkmate.authFullAccess.json new file mode 100644 index 0000000..65749de --- /dev/null +++ b/lexicons/blue.checkmate.authFullAccess.json @@ -0,0 +1,18 @@ +{ + "lexicon": 1, + "id": "blue.checkmate.authFullAccess", + "defs": { + "main": { + "type": "permission-set", + "title": "Full checkmate.blue Access", + "detail": "Create and manage chess games and challenges on checkmate.blue.", + "permissions": [ + { + "type": "permission", + "resource": "repo", + "collection": ["blue.checkmate.game", "blue.checkmate.challenge"] + } + ] + } + } +} diff --git a/src/lib/bluesky.ts b/src/lib/bluesky.ts index a2d0bcb..b92db18 100644 --- a/src/lib/bluesky.ts +++ b/src/lib/bluesky.ts @@ -1,99 +1,3 @@ -import type { Agent } from '@atproto/api'; - -interface Facet { - index: { byteStart: number; byteEnd: number }; - features: Array< - | { $type: 'app.bsky.richtext.facet#mention'; did: string } - | { $type: 'app.bsky.richtext.facet#link'; uri: string } - >; -} - -const encoder = new TextEncoder(); - -/** Compute the byte offset of a substring within a string (UTF-8). */ -function byteOffset(text: string, charIndex: number): number { - return encoder.encode(text.slice(0, charIndex)).byteLength; -} - -/** Build facets for @mentions and URLs in post text. */ -export function buildFacets( - text: string, - knownHandles: Map, -): Facet[] { - const facets: Facet[] = []; - - // Detect @mentions - const mentionRe = /(^|[\s(])@([a-zA-Z0-9.-]+(?:\.[a-zA-Z]{2,}))/g; - let match; - while ((match = mentionRe.exec(text)) !== null) { - const handle = match[2]; - const did = knownHandles.get(handle); - if (!did) continue; - - const mentionStart = match.index + match[1].length; - const mentionText = `@${handle}`; - facets.push({ - index: { - byteStart: byteOffset(text, mentionStart), - byteEnd: byteOffset(text, mentionStart + mentionText.length), - }, - features: [{ $type: 'app.bsky.richtext.facet#mention', did }], - }); - } - - // Detect URLs - const urlRe = /https?:\/\/[^\s)]+/g; - while ((match = urlRe.exec(text)) !== null) { - facets.push({ - index: { - byteStart: byteOffset(text, match.index), - byteEnd: byteOffset(text, match.index + match[0].length), - }, - features: [{ $type: 'app.bsky.richtext.facet#link', uri: match[0] }], - }); - } - - return facets; -} - -/** Post a Bluesky post with auto-detected facets for mentions and links. */ -export async function postToBluesky( - agent: Agent, - text: string, - knownHandles: Map, - embedUrl?: string, - embedTitle?: string, - embedDescription?: string, -): Promise<{ uri: string; cid: string }> { - const facets = buildFacets(text, knownHandles); - - const record: Record = { - $type: 'app.bsky.feed.post', - text, - facets, - createdAt: new Date().toISOString(), - }; - - if (embedUrl) { - record.embed = { - $type: 'app.bsky.embed.external', - external: { - uri: embedUrl, - title: embedTitle ?? 'checkmate.blue', - description: embedDescription ?? 'Chess on the Atmosphere', - }, - }; - } - - const response = await agent.com.atproto.repo.createRecord({ - repo: agent.assertDid, - collection: 'app.bsky.feed.post', - record, - }); - - return { uri: response.data.uri, cid: response.data.cid }; -} - /** Compose the default text for a challenge post. */ export function composeChallengePost( opponentHandle: string, @@ -136,7 +40,6 @@ export function composeGameResultPost( const label = drawReasons[result.reason] ?? 'Draw'; text = `${label} with ${opponent} on checkmate.blue${moveSuffix}`; } else { - // Loss if (result.reason === 'checkmate') { text = `Got checkmated by ${opponent} on checkmate.blue -- good game!${moveSuffix}`; } else { @@ -154,3 +57,9 @@ export function graphemeCount(text: string): number { for (const _ of segmenter.segment(text)) count++; return count; } + +/** Open the Bluesky compose page with pre-populated text. */ +export function openBlueskyCompose(text: string): void { + const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(text)}`; + window.open(url, '_blank'); +} diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 890d9b3..3369f41 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -1,7 +1,7 @@ import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; import { buildAtprotoLoopbackClientMetadata } from '@atproto/oauth-types'; -const SCOPE = 'atproto transition:generic'; +const SCOPE = 'atproto include:blue.checkmate.authFullAccess'; const isProd = typeof window !== 'undefined' && window.location.hostname === 'checkmate.blue'; diff --git a/src/lib/stores/auth.svelte.ts b/src/lib/stores/auth.svelte.ts index 1caf70d..3566452 100644 --- a/src/lib/stores/auth.svelte.ts +++ b/src/lib/stores/auth.svelte.ts @@ -1,7 +1,7 @@ import { Agent } from '@atproto/api'; import type { BrowserOAuthClient } from '@atproto/oauth-client-browser'; -const SCOPE = 'atproto transition:generic'; +import { SCOPE } from '$lib/oauth'; let agent: Agent | null = $state(null); let did: string | undefined = $state(undefined); diff --git a/src/routes/game/[did]/[rkey]/+page.svelte b/src/routes/game/[did]/[rkey]/+page.svelte index 193235c..2ebcacc 100644 --- a/src/routes/game/[did]/[rkey]/+page.svelte +++ b/src/routes/game/[did]/[rkey]/+page.svelte @@ -19,7 +19,7 @@ import type { GameRecord } from '$lib/types'; import { JetstreamConnection } from '$lib/jetstream'; import { resolveIdentity } from '$lib/microcosm'; - import { composeChallengePost, composeGameResultPost, postToBluesky, graphemeCount } from '$lib/bluesky'; + import { composeChallengePost, composeGameResultPost, openBlueskyCompose, graphemeCount } from '$lib/bluesky'; import LoginButton from '$lib/components/LoginButton.svelte'; import type { PieceSymbol } from 'chess.js'; @@ -39,9 +39,7 @@ let rematchCreating = $state(false); let analyzingOnLichess = $state(false); let shareText = $state(''); - let sharing = $state(false); let shared = $state(false); - let shareError = $state(''); let dismissShare = $state(false); let rematchOffer: { did: string; rkey: string } | null = $state(null); let rematchDismissed = $state(false); @@ -94,7 +92,6 @@ shareText = ''; shared = false; dismissShare = false; - shareError = ''; rematchCreating = false; rematchOffer = null; rematchDismissed = false; @@ -649,32 +646,10 @@ } }); - async function shareResult() { - if (!auth.agent || !shareText.trim()) return; - sharing = true; - shareError = ''; - - try { - const opDid = game.myColor === 'white' ? game.blackDid : game.whiteDid; - const handles = new Map(); - if (opDid && opponentHandle) { - handles.set(opponentHandle, opDid); - } - - await postToBluesky( - auth.agent, - shareText, - handles, - gameUrl(), - `${game.whiteHandle ?? 'White'} vs ${game.blackHandle ?? 'Black'}`, - `${game.result!.result} by ${game.result!.reason}`, - ); - shared = true; - } catch (e) { - shareError = e instanceof Error ? e.message : 'Failed to post'; - } finally { - sharing = false; - } + function shareResult() { + if (!shareText.trim()) return; + openBlueskyCompose(shareText); + shared = true; } function flipBoard() { @@ -682,9 +657,7 @@ } let copied = $state(false); - let posting = $state(false); let posted = $state(false); - let postError = $state(''); let dmCopied = $state(false); function gameUrl(): string { @@ -704,33 +677,11 @@ window.open('https://bsky.app/messages', '_blank'); } - async function postChallengeToBluesky() { - if (!auth.agent || !opponentHandle || posted) return; - posting = true; - postError = ''; - - const opponentDid = game.myColor === 'white' ? game.blackDid : game.whiteDid; + function postChallengeToBluesky() { + if (!opponentHandle || posted) return; const text = composeChallengePost(opponentHandle, gameUrl()); - const handles = new Map(); - if (opponentDid && opponentHandle) { - handles.set(opponentHandle, opponentDid); - } - - try { - await postToBluesky( - auth.agent, - text, - handles, - gameUrl(), - 'Chess Challenge on checkmate.blue', - `${auth.handle} wants to play chess!`, - ); - posted = true; - } catch (e) { - postError = e instanceof Error ? e.message : 'Failed to post'; - } finally { - posting = false; - } + openBlueskyCompose(text); + posted = true; } const isWaiting = $derived(game.status === 'waiting'); @@ -809,10 +760,9 @@ {:else} {/if}