a Fabric Minecraft mod that connects the game to the AT Protocol.

feat(atproto): implement identity linking, auth, and command system

Add core AT Protocol integration with Slingshot-based PDS resolution,
player identity linking, authenticated sessions, and in-game commands.
Includes session persistence, token refresh, and expanded documentation.

ewancroft.uk a60ef032 11712632

verified
+193 -34
README.md
··· 4 4 5 5 ## ⚠️ Project Status 6 6 7 - **This project is in early planning stages and is NOT ready for production use.** The current repository contains a Fabric mod template to establish the initial project structure. Active development and implementation are ongoing. 7 + **This project is in active development and is NOT ready for production use.** Identity linking and authentication are now implemented, but stat syncing and other features are still in progress. 8 8 9 9 ## Overview 10 10 11 11 atproto-connect aims to integrate Minecraft gameplay with the AT Protocol (the protocol powering Bluesky), allowing game data to be synced to AT Protocol lexicons. This enables decentralized storage and sharing of Minecraft data across the federated network. 12 12 13 - ## Goals 13 + ## Current Features 14 + 15 + ### Identity Linking & Authentication ✓ 16 + 17 + Players can link their Minecraft accounts to their AT Protocol identities and authenticate to enable data syncing: 18 + 19 + **Basic Commands:** 20 + 21 + - **`/atproto link <handle or DID>`** - Link your Minecraft UUID to your AT Protocol identity (no login required) 22 + - **`/atproto login <handle> <app-password>`** - Authenticate to enable data syncing 23 + - **`/atproto logout`** - Remove authentication (keeps identity link) 24 + - **`/atproto unlink`** - Remove identity link and authentication 25 + - **`/atproto whoami`** - View your linked identity and auth status 26 + - **`/atproto status`** - Quick status check 27 + - **`/atproto whois <player or handle>`** - Look up another player's identity 28 + 29 + **Example Workflow:** 14 30 15 - - **Decentralized Data Sync**: Publish Minecraft gameplay data to AT Protocol repositories 16 - - **Cross-Server Statistics**: Enable player statistics to persist across different servers via AT Protocol 17 - - **Social Integration**: Connect Minecraft achievements and activities with the broader AT Protocol ecosystem 18 - - **Lexicon-Based Storage**: Utilize AT Protocol's schema system for structured game data 31 + ```plaintext 32 + # 1. Link your identity (read-only) 33 + /atproto link alice.bsky.social 34 + ✓ Successfully linked to AT Protocol! 35 + Handle: alice.bsky.social 36 + DID: did:plc:abcdef123456 37 + PDS: https://morel.us-east.host.bsky.network 19 38 20 - ## Use Cases 39 + # 2. Authenticate with an App Password 40 + /atproto login alice.bsky.social abcd-1234-efgh-5678 41 + ✓ Successfully authenticated! 42 + You can now sync your Minecraft data to AT Protocol! 21 43 22 - ### Player Statistics & Leaderboards 44 + # 3. Check your status 45 + /atproto whoami 46 + ━━━ Your AT Protocol Identity ━━━ 47 + Handle: alice.bsky.social 48 + DID: did:plc:abcdef123456 49 + Linked: 5 minutes ago 50 + Last Verified: 5 minutes ago 23 51 24 - Sync player statistics (blocks mined, mobs killed, distance traveled, etc.) to AT Protocol lexicons, enabling: 52 + Authentication: ✓ Active 53 + You can sync data to AT Protocol 54 + ``` 25 55 26 - - Global leaderboards that work across multiple servers 27 - - Historical stat tracking independent of individual server databases 28 - - Player achievement portfolios visible on AT Protocol clients 56 + ### Key Features 57 + 58 + - **Slingshot Integration**: Uses [Slingshot by Microcosm](https://slingshot.microcosm.blue) for fast, cached PDS resolution 59 + - **App Password Support**: Secure authentication using AT Protocol app passwords (never use your main password!) 60 + - **Automatic Token Refresh**: Access tokens are automatically refreshed before expiration 61 + - **Multi-PDS Support**: Works with any AT Protocol PDS, not just Bluesky 62 + - **Persistent Sessions**: Authentication survives server restarts 63 + 64 + ### Getting an App Password 65 + 66 + 1. Go to your AT Protocol account settings (e.g., Bluesky Settings → Privacy and Security → App Passwords) 67 + 2. Create a new app password with a descriptive name (e.g., "Minecraft Server") 68 + 3. Copy it immediately (you won't see it again!) 69 + 4. Use it in `/atproto login` 70 + 5. **Never share your app password or use your main account password!** 29 71 30 72 ### Future Possibilities 31 73 32 - - Server announcements via AT Protocol feeds 74 + - Automatic stat syncing at configurable intervals 75 + - Achievement announcements via AT Protocol feeds 33 76 - Cross-server player reputation systems 34 - - Decentralized mod/plugin distribution 77 + - Privacy controls for what data gets synced 35 78 - In-game social features tied to AT Protocol identities 36 79 37 80 ## Technical Stack ··· 40 83 - **Mod Loader**: Fabric API 41 84 - **Protocol**: AT Protocol (atproto) 42 85 - **Language**: Kotlin (with Java interop) 86 + - **Dependencies**: 87 + 88 + - fabric-language-kotlin 1.13.8+kotlin.2.3.0 89 + - kotlinx-serialization for JSON handling 90 + - kotlinx-coroutines for async operations 91 + 92 + - **Identity Resolution**: [Slingshot](https://slingshot.microcosm.blue) by Microcosm 93 + - **Authentication**: AT Protocol OAuth/App Passwords 43 94 44 95 ## Installation 45 96 46 97 ### For Users 47 98 48 - *Installation instructions will be added once the mod reaches a usable state.* 99 + 1. Install [Fabric Loader](https://fabricmc.net/use/) for Minecraft 1.21.10 100 + 2. Download and install [Fabric API](https://modrinth.com/mod/fabric-api) 101 + 3. Download and install [Fabric Language Kotlin](https://modrinth.com/mod/fabric-language-kotlin) 102 + 4. Place the atproto-connect JAR in your `mods` folder 103 + 5. Launch the game and use `/atproto help` to see available commands 49 104 50 105 ### For Developers 51 106 ··· 62 117 ./gradlew build 63 118 ``` 64 119 65 - ## Development Roadmap 120 + The built JAR will be in `build/libs/`. 121 + 122 + ## Project Structure 123 + 124 + ```plaintext 125 + src/main/ 126 + ├── kotlin/com/jollywhoppers/ 127 + │ ├── Atprotoconnect.kt # Main mod initializer 128 + │ └── atproto/ 129 + │ ├── AtProtoClient.kt # HTTP client with Slingshot integration 130 + │ ├── AtProtoSessionManager.kt # Authentication & token management 131 + │ ├── AtProtoCommands.kt # Command handlers 132 + │ └── PlayerIdentityStore.kt # UUID<->DID mapping storage 133 + └── resources/ 134 + ├── fabric.mod.json # Mod metadata 135 + └── lexicons/ # Lexicon schemas 136 + ├── com.jollywhoppers.minecraft.defs.json 137 + ├── com.jollywhoppers.minecraft.player.profile.json 138 + ├── com.jollywhoppers.minecraft.player.stats.json 139 + ├── com.jollywhoppers.minecraft.player.session.json 140 + ├── com.jollywhoppers.minecraft.achievement.json 141 + ├── com.jollywhoppers.minecraft.leaderboard.json 142 + └── com.jollywhoppers.minecraft.server.status.json 143 + ``` 144 + 145 + ## Lexicon Schemas 146 + 147 + The mod defines several AT Protocol lexicon schemas under the `com.jollywhoppers.minecraft.*` namespace: 148 + 149 + - **Player Profile** - Links Minecraft UUIDs to AT Protocol DIDs 150 + - **Player Stats** - Snapshots of player statistics for leaderboards 151 + - **Player Sessions** - Play session tracking (join/leave times) 152 + - **Achievements** - Records of earned achievements/advancements 153 + - **Leaderboards** - Pre-computed leaderboard entries 154 + - **Server Status** - Server information and status 66 155 67 - - [ ] Design lexicon schemas for Minecraft data types 68 - - [ ] Implement AT Protocol client integration 69 - - [ ] Create configuration system for AT Protocol credentials 70 - - [ ] Build data collection hooks for player statistics 71 - - [ ] Develop sync engine for pushing data to AT Protocol 72 - - [ ] Add privacy controls and data filtering options 73 - - [ ] Create example lexicons and test environments 74 - - [ ] Write comprehensive documentation 156 + See `src/main/resources/lexicons/README.md` for detailed schema documentation. 75 157 76 - ## Architecture (Planned) 158 + ## Architecture 77 159 78 160 ```plaintext 79 - Minecraft Server (Fabric) 161 + Player Commands (/atproto login, /atproto link, etc.) 162 + 163 + AtProtoCommands (Kotlin Coroutines) 80 164 81 - atproto-connect Mod 165 + ┌────────────────────────────────────────┐ 166 + │ AtProtoSessionManager │ 167 + │ (Authentication & Token Storage) │ 168 + └────────────────────────────────────────┘ 169 + 170 + ┌────────────────────────────────────────┐ 171 + │ AtProtoClient │ 172 + │ (HTTP + XRPC + Slingshot) │ 173 + └────────────────────────────────────────┘ 82 174 83 - AT Protocol Client Library 175 + ┌────────────────────────────────────────┐ 176 + │ Slingshot (Microcosm Blue) │ 177 + │ Fast PDS Resolution & Caching │ 178 + └────────────────────────────────────────┘ 84 179 85 - AT Protocol PDS (Personal Data Server) 180 + AT Protocol Network 181 + - Player's PDS (Data & Auth) 182 + - plc.directory (DID Resolution) 86 183 87 - Federated AT Protocol Network 184 + Local Storage 185 + - player-identities.json (UUID↔DID mappings) 186 + - player-sessions.json (Auth tokens) 88 187 ``` 89 188 189 + ## Authentication & Security 190 + 191 + ### How It Works 192 + 193 + 1. **Link Identity**: Players link their Minecraft UUID to their AT Protocol DID (read-only, no login required) 194 + 2. **Authenticate**: Players log in with their handle and an app password to create an authenticated session 195 + 3. **Token Management**: The mod stores JWT access and refresh tokens securely 196 + 4. **Auto-Refresh**: Access tokens are automatically refreshed before expiration 197 + 5. **Data Syncing**: Authenticated players can sync their Minecraft data to their PDS 198 + 199 + ### Security Best Practices 200 + 201 + - ✅ **Always use App Passwords**, never main account passwords 202 + - ✅ Create a unique app password for each Minecraft server 203 + - ✅ Revoke unused app passwords regularly 204 + - ✅ Server operators should secure the `config/atproto-connect/` directory 205 + - ✅ Tokens are stored in JSON files - protect file permissions appropriately 206 + 207 + ### Token Storage 208 + 209 + - **Location**: `config/atproto-connect/player-sessions.json` 210 + - **Contents**: Access and refresh JWTs for authenticated players 211 + - **Security**: File permissions should restrict access to server owner only 212 + - **Lifetime**: Access tokens expire after ~2 hours, refresh tokens last longer 213 + 214 + ## Development Roadmap 215 + 216 + - [x] Design lexicon schemas for Minecraft data types 217 + - [x] Implement AT Protocol client with Slingshot integration 218 + - [x] Create identity linking system 219 + - [x] Implement authentication with app passwords 220 + - [x] Build session management with automatic token refresh 221 + - [ ] Add authenticated record creation (writing stats) 222 + - [ ] Build data collection hooks for player statistics 223 + - [ ] Implement automatic stat syncing 224 + - [ ] Add privacy controls and data filtering options 225 + - [ ] Create example AppView for displaying Minecraft data 226 + - [ ] Write comprehensive documentation 227 + - [ ] Add automated tests 228 + 90 229 ## Contributing 91 230 92 - As this project is in early development, contribution guidelines will be established once the core architecture is defined. If you're interested in contributing, please open an issue to discuss your ideas. 231 + This project is in active development. If you're interested in contributing: 93 232 94 - ## AT Protocol Resources 233 + 1. Check the Issues page for open tasks 234 + 2. Fork the repository 235 + 3. Create a feature branch 236 + 4. Submit a pull request with a clear description 237 + 238 + ## Resources 239 + 240 + ### AT Protocol 95 241 96 242 - [AT Protocol Documentation](https://atproto.com/) 97 243 - [Lexicon Specifications](https://atproto.com/specs/lexicon) 98 - - [AT Protocol SDKs](https://atproto.com/sdks) 244 + - [XRPC API Reference](https://atproto.com/specs/xrpc) 245 + - [OAuth Specification](https://atproto.com/specs/oauth) 246 + - [Bluesky API Docs](https://docs.bsky.app/) 247 + 248 + ### Microcosm 249 + 250 + - [Slingshot Documentation](https://slingshot.microcosm.blue/) 251 + - [Microcosm Project](https://microcosm.blue/) 99 252 100 253 ## License 101 254 102 - License information to be added. 255 + TBD 103 256 104 257 ## Disclaimer 105 258 106 259 This is an experimental project exploring the intersection of decentralized protocols and gaming. It is not affiliated with or endorsed by Mojang, Microsoft, or the official AT Protocol team. 260 + 261 + ## Acknowledgments 262 + 263 + - [Microcosm](https://microcosm.blue) for Slingshot, which makes PDS resolution fast and reliable 264 + - The AT Protocol team for building an open, decentralized social network protocol 265 + - The Fabric community for excellent mod development tools 107 266 108 267 --- 109 268
+68 -7
src/main/kotlin/com/jollywhoppers/Atprotoconnect.kt
··· 1 1 package com.jollywhoppers 2 2 3 + import com.jollywhoppers.atproto.AtProtoClient 4 + import com.jollywhoppers.atproto.AtProtoCommands 5 + import com.jollywhoppers.atproto.AtProtoSessionManager 6 + import com.jollywhoppers.atproto.PlayerIdentityStore 3 7 import net.fabricmc.api.ModInitializer 8 + import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback 9 + import net.fabricmc.loader.api.FabricLoader 4 10 import org.slf4j.LoggerFactory 5 11 6 12 object Atprotoconnect : ModInitializer { 7 13 private val logger = LoggerFactory.getLogger("atproto-connect") 14 + private const val MOD_ID = "atproto-connect" 8 15 9 - override fun onInitialize() { 10 - // This code runs as soon as Minecraft is in a mod-load-ready state. 11 - // However, some things (like resources) may still be uninitialized. 12 - // Proceed with mild caution. 13 - logger.info("Hello Fabric world!") 14 - } 15 - } 16 + // AT Protocol components 17 + lateinit var atProtoClient: AtProtoClient 18 + private set 19 + 20 + lateinit var identityStore: PlayerIdentityStore 21 + private set 22 + 23 + lateinit var sessionManager: AtProtoSessionManager 24 + private set 25 + 26 + lateinit var commands: AtProtoCommands 27 + private set 28 + 29 + override fun onInitialize() { 30 + logger.info("Initializing atproto-connect mod") 31 + 32 + try { 33 + // Initialize AT Protocol client with Slingshot for PDS resolution 34 + atProtoClient = AtProtoClient( 35 + slingshotUrl = "https://slingshot.microcosm.blue", 36 + fallbackPdsUrl = "https://bsky.social" 37 + ) 38 + logger.info("AT Protocol client initialized with Slingshot resolver") 39 + 40 + // Initialize identity store 41 + val configDir = FabricLoader.getInstance().configDir 42 + val identityStorePath = configDir.resolve("$MOD_ID/player-identities.json") 43 + identityStore = PlayerIdentityStore(identityStorePath) 44 + logger.info("Player identity store initialized at: $identityStorePath") 45 + 46 + // Initialize session manager 47 + val sessionStorePath = configDir.resolve("$MOD_ID/player-sessions.json") 48 + sessionManager = AtProtoSessionManager(sessionStorePath, atProtoClient) 49 + logger.info("Session manager initialized at: $sessionStorePath") 50 + 51 + // Initialize command handler 52 + commands = AtProtoCommands(atProtoClient, identityStore, sessionManager) 53 + 54 + // Register commands 55 + CommandRegistrationCallback.EVENT.register { dispatcher, _, _ -> 56 + commands.register(dispatcher) 57 + logger.info("AT Protocol commands registered") 58 + } 59 + 60 + logger.info("atproto-connect mod successfully initialized!") 61 + logger.info("Players can use /atproto help to see available commands") 62 + } catch (e: Exception) { 63 + logger.error("Failed to initialize atproto-connect mod", e) 64 + } 65 + } 66 + 67 + /** 68 + * Gets the mod version from the metadata. 69 + */ 70 + fun getVersion(): String { 71 + return FabricLoader.getInstance() 72 + .getModContainer(MOD_ID) 73 + .map { it.metadata.version.friendlyString } 74 + .orElse("unknown") 75 + } 76 + }
+385
src/main/kotlin/com/jollywhoppers/atproto/AtProtoClient.kt
··· 1 + package com.jollywhoppers.atproto 2 + 3 + import kotlinx.serialization.Serializable 4 + import kotlinx.serialization.json.Json 5 + import org.slf4j.LoggerFactory 6 + import java.net.URI 7 + import java.net.http.HttpClient 8 + import java.net.http.HttpRequest 9 + import java.net.http.HttpResponse 10 + import java.time.Duration 11 + import java.util.* 12 + 13 + /** 14 + * Enhanced AT Protocol client with PDS resolution via Slingshot. 15 + * Handles identity resolution, authentication, and XRPC requests. 16 + */ 17 + class AtProtoClient( 18 + private val slingshotUrl: String = "https://slingshot.microcosm.blue", 19 + private val fallbackPdsUrl: String = "https://bsky.social" 20 + ) { 21 + private val logger = LoggerFactory.getLogger("atproto-connect") 22 + private val httpClient = HttpClient.newBuilder() 23 + .connectTimeout(Duration.ofSeconds(10)) 24 + .followRedirects(HttpClient.Redirect.NORMAL) 25 + .build() 26 + 27 + private val json = Json { 28 + ignoreUnknownKeys = true 29 + isLenient = true 30 + prettyPrint = false 31 + } 32 + 33 + @Serializable 34 + data class MiniDoc( 35 + val did: String, 36 + val handle: String, 37 + val pds: String, 38 + val pdsKnown: Boolean = false 39 + ) 40 + 41 + @Serializable 42 + data class DidDocument( 43 + val id: String, 44 + val alsoKnownAs: List<String> = emptyList(), 45 + val verificationMethod: List<VerificationMethod> = emptyList(), 46 + val service: List<Service> = emptyList() 47 + ) 48 + 49 + @Serializable 50 + data class VerificationMethod( 51 + val id: String, 52 + val type: String, 53 + val controller: String, 54 + val publicKeyMultibase: String? = null 55 + ) 56 + 57 + @Serializable 58 + data class Service( 59 + val id: String, 60 + val type: String, 61 + val serviceEndpoint: String 62 + ) 63 + 64 + @Serializable 65 + data class HandleResolution( 66 + val did: String 67 + ) 68 + 69 + @Serializable 70 + data class ProfileView( 71 + val did: String, 72 + val handle: String, 73 + val displayName: String? = null, 74 + val description: String? = null, 75 + val avatar: String? = null 76 + ) 77 + 78 + @Serializable 79 + data class CreateSessionRequest( 80 + val identifier: String, 81 + val password: String 82 + ) 83 + 84 + @Serializable 85 + data class CreateSessionResponse( 86 + val did: String, 87 + val handle: String, 88 + val email: String? = null, 89 + val accessJwt: String, 90 + val refreshJwt: String, 91 + val didDoc: DidDocument? = null 92 + ) 93 + 94 + @Serializable 95 + data class RefreshSessionRequest( 96 + val refreshJwt: String 97 + ) 98 + 99 + /** 100 + * Resolves an identifier (handle or DID) to a MiniDoc using Slingshot. 101 + * This is the preferred method as it's fast and cached. 102 + */ 103 + suspend fun resolveMiniDoc(identifier: String): Result<MiniDoc> = runCatching { 104 + logger.info("Resolving identifier via Slingshot: $identifier") 105 + 106 + val request = HttpRequest.newBuilder() 107 + .uri(URI.create("$slingshotUrl/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}")) 108 + .GET() 109 + .timeout(Duration.ofSeconds(10)) 110 + .build() 111 + 112 + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 113 + 114 + if (response.statusCode() != 200) { 115 + throw Exception("Failed to resolve via Slingshot: HTTP ${response.statusCode()}") 116 + } 117 + 118 + val miniDoc = json.decodeFromString<MiniDoc>(response.body()) 119 + logger.info("Resolved ${miniDoc.handle} -> PDS: ${miniDoc.pds}") 120 + miniDoc 121 + } 122 + 123 + /** 124 + * Resolves an AT Protocol handle to a DID. 125 + * Falls back to standard resolution if Slingshot fails. 126 + */ 127 + suspend fun resolveHandle(handle: String): Result<String> = runCatching { 128 + logger.info("Resolving handle: $handle") 129 + 130 + // Try Slingshot's MiniDoc first 131 + try { 132 + val miniDoc = resolveMiniDoc(handle).getOrThrow() 133 + return@runCatching miniDoc.did 134 + } catch (e: Exception) { 135 + logger.warn("Slingshot resolution failed, trying fallback: ${e.message}") 136 + } 137 + 138 + // Fallback to standard XRPC resolution 139 + val request = HttpRequest.newBuilder() 140 + .uri(URI.create("$fallbackPdsUrl/xrpc/com.atproto.identity.resolveHandle?handle=$handle")) 141 + .GET() 142 + .timeout(Duration.ofSeconds(10)) 143 + .build() 144 + 145 + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 146 + 147 + if (response.statusCode() != 200) { 148 + throw Exception("Failed to resolve handle: HTTP ${response.statusCode()}") 149 + } 150 + 151 + val resolution = json.decodeFromString<HandleResolution>(response.body()) 152 + logger.info("Resolved handle $handle to DID: ${resolution.did}") 153 + resolution.did 154 + } 155 + 156 + /** 157 + * Resolves a DID to its DID Document. 158 + * Supports did:plc and did:web methods. 159 + */ 160 + suspend fun resolveDid(did: String): Result<DidDocument> = runCatching { 161 + logger.info("Resolving DID: $did") 162 + 163 + val url = when { 164 + did.startsWith("did:plc:") -> { 165 + val identifier = did.removePrefix("did:plc:") 166 + "https://plc.directory/$identifier" 167 + } 168 + did.startsWith("did:web:") -> { 169 + val domain = did.removePrefix("did:web:") 170 + "https://$domain/.well-known/did.json" 171 + } 172 + else -> throw IllegalArgumentException("Unsupported DID method: $did") 173 + } 174 + 175 + val request = HttpRequest.newBuilder() 176 + .uri(URI.create(url)) 177 + .GET() 178 + .timeout(Duration.ofSeconds(10)) 179 + .build() 180 + 181 + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 182 + 183 + if (response.statusCode() != 200) { 184 + throw Exception("Failed to resolve DID: HTTP ${response.statusCode()}") 185 + } 186 + 187 + val didDoc = json.decodeFromString<DidDocument>(response.body()) 188 + logger.info("Resolved DID $did successfully") 189 + didDoc 190 + } 191 + 192 + /** 193 + * Gets a profile from the AT Protocol network. 194 + * Uses Slingshot for PDS resolution if needed. 195 + */ 196 + suspend fun getProfile(actor: String, pdsUrl: String? = null): Result<ProfileView> = runCatching { 197 + logger.info("Fetching profile for: $actor") 198 + 199 + val serviceUrl = pdsUrl ?: run { 200 + // Resolve PDS if not provided 201 + try { 202 + val miniDoc = resolveMiniDoc(actor).getOrThrow() 203 + miniDoc.pds 204 + } catch (e: Exception) { 205 + logger.warn("Could not resolve PDS, using fallback: ${e.message}") 206 + fallbackPdsUrl 207 + } 208 + } 209 + 210 + val request = HttpRequest.newBuilder() 211 + .uri(URI.create("$serviceUrl/xrpc/app.bsky.actor.getProfile?actor=$actor")) 212 + .GET() 213 + .timeout(Duration.ofSeconds(10)) 214 + .build() 215 + 216 + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 217 + 218 + if (response.statusCode() != 200) { 219 + throw Exception("Failed to get profile: HTTP ${response.statusCode()}: ${response.body()}") 220 + } 221 + 222 + val profile = json.decodeFromString<ProfileView>(response.body()) 223 + logger.info("Retrieved profile for ${profile.handle} (${profile.did})") 224 + profile 225 + } 226 + 227 + /** 228 + * Creates an authenticated session using identifier and app password. 229 + * This is the primary authentication method for the mod. 230 + */ 231 + suspend fun createSession(identifier: String, password: String): Result<CreateSessionResponse> = runCatching { 232 + logger.info("Creating session for: $identifier") 233 + 234 + // Resolve to find the correct PDS 235 + val pdsUrl = try { 236 + val miniDoc = resolveMiniDoc(identifier).getOrThrow() 237 + miniDoc.pds 238 + } catch (e: Exception) { 239 + logger.warn("Could not resolve PDS via Slingshot, trying fallback: ${e.message}") 240 + fallbackPdsUrl 241 + } 242 + 243 + val requestBody = CreateSessionRequest( 244 + identifier = identifier, 245 + password = password 246 + ) 247 + 248 + val request = HttpRequest.newBuilder() 249 + .uri(URI.create("$pdsUrl/xrpc/com.atproto.server.createSession")) 250 + .POST(HttpRequest.BodyPublishers.ofString(json.encodeToString(CreateSessionRequest.serializer(), requestBody))) 251 + .header("Content-Type", "application/json") 252 + .timeout(Duration.ofSeconds(15)) 253 + .build() 254 + 255 + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 256 + 257 + if (response.statusCode() != 200) { 258 + val errorBody = response.body() 259 + logger.error("Session creation failed: HTTP ${response.statusCode()}: $errorBody") 260 + throw Exception("Failed to create session: HTTP ${response.statusCode()}: $errorBody") 261 + } 262 + 263 + val session = json.decodeFromString<CreateSessionResponse>(response.body()) 264 + logger.info("Session created successfully for ${session.handle}") 265 + session 266 + } 267 + 268 + /** 269 + * Refreshes an existing session using a refresh token. 270 + */ 271 + suspend fun refreshSession(refreshJwt: String, pdsUrl: String): Result<CreateSessionResponse> = runCatching { 272 + logger.info("Refreshing session") 273 + 274 + val requestBody = RefreshSessionRequest(refreshJwt = refreshJwt) 275 + 276 + val request = HttpRequest.newBuilder() 277 + .uri(URI.create("$pdsUrl/xrpc/com.atproto.server.refreshSession")) 278 + .POST(HttpRequest.BodyPublishers.ofString(json.encodeToString(RefreshSessionRequest.serializer(), requestBody))) 279 + .header("Content-Type", "application/json") 280 + .header("Authorization", "Bearer $refreshJwt") 281 + .timeout(Duration.ofSeconds(15)) 282 + .build() 283 + 284 + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 285 + 286 + if (response.statusCode() != 200) { 287 + throw Exception("Failed to refresh session: HTTP ${response.statusCode()}") 288 + } 289 + 290 + json.decodeFromString<CreateSessionResponse>(response.body()) 291 + } 292 + 293 + /** 294 + * Makes an authenticated XRPC request. 295 + */ 296 + suspend fun xrpcRequest( 297 + method: String, 298 + endpoint: String, 299 + accessJwt: String, 300 + pdsUrl: String, 301 + body: String? = null 302 + ): Result<String> = runCatching { 303 + val requestBuilder = HttpRequest.newBuilder() 304 + .uri(URI.create("$pdsUrl/xrpc/$endpoint")) 305 + .header("Authorization", "Bearer $accessJwt") 306 + .header("Content-Type", "application/json") 307 + .timeout(Duration.ofSeconds(15)) 308 + 309 + val request = when (method.uppercase()) { 310 + "GET" -> requestBuilder.GET().build() 311 + "POST" -> requestBuilder.POST( 312 + HttpRequest.BodyPublishers.ofString(body ?: "{}") 313 + ).build() 314 + "PUT" -> requestBuilder.PUT( 315 + HttpRequest.BodyPublishers.ofString(body ?: "{}") 316 + ).build() 317 + "DELETE" -> requestBuilder.DELETE().build() 318 + else -> throw IllegalArgumentException("Unsupported HTTP method: $method") 319 + } 320 + 321 + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 322 + 323 + if (response.statusCode() !in 200..299) { 324 + throw Exception("XRPC request failed: HTTP ${response.statusCode()}: ${response.body()}") 325 + } 326 + 327 + response.body() 328 + } 329 + 330 + /** 331 + * Validates that a DID is properly formatted. 332 + */ 333 + fun isValidDid(did: String): Boolean { 334 + return did.matches(Regex("^did:(plc|web):[a-zA-Z0-9._:%-]+$")) 335 + } 336 + 337 + /** 338 + * Validates that a handle is properly formatted. 339 + */ 340 + fun isValidHandle(handle: String): Boolean { 341 + return handle.matches(Regex("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")) 342 + } 343 + 344 + /** 345 + * Determines if the input is a DID or handle and resolves accordingly. 346 + * Returns (DID, handle, PDS URL). 347 + */ 348 + suspend fun resolveIdentifier(identifier: String): Result<Triple<String, String, String>> = runCatching { 349 + when { 350 + identifier.startsWith("did:") -> { 351 + if (!isValidDid(identifier)) { 352 + throw IllegalArgumentException("Invalid DID format") 353 + } 354 + // Try Slingshot first 355 + try { 356 + val miniDoc = resolveMiniDoc(identifier).getOrThrow() 357 + Triple(miniDoc.did, miniDoc.handle, miniDoc.pds) 358 + } catch (e: Exception) { 359 + // Fallback to DID resolution 360 + val didDoc = resolveDid(identifier).getOrThrow() 361 + val handle = didDoc.alsoKnownAs.firstOrNull() 362 + ?.removePrefix("at://") 363 + ?: throw Exception("No handle found in DID document") 364 + val pdsService = didDoc.service.firstOrNull { it.type == "AtprotoPersonalDataServer" } 365 + ?: throw Exception("No PDS service found in DID document") 366 + Triple(identifier, handle, pdsService.serviceEndpoint) 367 + } 368 + } 369 + else -> { 370 + if (!isValidHandle(identifier)) { 371 + throw IllegalArgumentException("Invalid handle format") 372 + } 373 + val miniDoc = resolveMiniDoc(identifier).getOrThrow() 374 + Triple(miniDoc.did, miniDoc.handle, miniDoc.pds) 375 + } 376 + } 377 + } 378 + 379 + private fun encodeURIComponent(value: String): String { 380 + return URI(null, null, null, -1, null, null, null) 381 + .resolve(value) 382 + .rawSchemeSpecificPart 383 + .replace("+", "%20") 384 + } 385 + }
+405
src/main/kotlin/com/jollywhoppers/atproto/AtProtoCommands.kt
··· 1 + package com.jollywhoppers.atproto 2 + 3 + import com.mojang.brigadier.CommandDispatcher 4 + import com.mojang.brigadier.arguments.StringArgumentType 5 + import com.mojang.brigadier.context.CommandContext 6 + import kotlinx.coroutines.CoroutineScope 7 + import kotlinx.coroutines.Dispatchers 8 + import kotlinx.coroutines.launch 9 + import net.minecraft.commands.CommandSourceStack 10 + import net.minecraft.commands.Commands 11 + import net.minecraft.network.chat.Component 12 + import net.minecraft.server.level.ServerPlayer 13 + import org.slf4j.LoggerFactory 14 + 15 + /** 16 + * Handles AT Protocol-related commands for players. 17 + * Provides commands to link, authenticate, and manage AT Protocol identities. 18 + */ 19 + class AtProtoCommands( 20 + private val client: AtProtoClient, 21 + private val identityStore: PlayerIdentityStore, 22 + private val sessionManager: AtProtoSessionManager 23 + ) { 24 + private val logger = LoggerFactory.getLogger("atproto-connect") 25 + private val coroutineScope = CoroutineScope(Dispatchers.IO) 26 + 27 + /** 28 + * Registers all AT Protocol commands. 29 + */ 30 + fun register(dispatcher: CommandDispatcher<CommandSourceStack>) { 31 + dispatcher.register( 32 + Commands.literal("atproto") 33 + .then( 34 + Commands.literal("link") 35 + .then( 36 + Commands.argument("identifier", StringArgumentType.greedyString()) 37 + .executes { context -> linkIdentity(context) } 38 + ) 39 + ) 40 + .then( 41 + Commands.literal("unlink") 42 + .executes { context -> unlinkIdentity(context) } 43 + ) 44 + .then( 45 + Commands.literal("login") 46 + .then( 47 + Commands.argument("identifier", StringArgumentType.string()) 48 + .then( 49 + Commands.argument("password", StringArgumentType.greedyString()) 50 + .executes { context -> login(context) } 51 + ) 52 + ) 53 + ) 54 + .then( 55 + Commands.literal("logout") 56 + .executes { context -> logout(context) } 57 + ) 58 + .then( 59 + Commands.literal("whoami") 60 + .executes { context -> whoami(context) } 61 + ) 62 + .then( 63 + Commands.literal("whois") 64 + .then( 65 + Commands.argument("identifier", StringArgumentType.greedyString()) 66 + .executes { context -> whois(context) } 67 + ) 68 + ) 69 + .then( 70 + Commands.literal("status") 71 + .executes { context -> status(context) } 72 + ) 73 + .executes { context -> help(context) } 74 + ) 75 + } 76 + 77 + /** 78 + * Links a player's Minecraft UUID to their AT Protocol identity (without authentication). 79 + */ 80 + private fun linkIdentity(context: CommandContext<CommandSourceStack>): Int { 81 + val player = context.source.playerOrException 82 + val identifier = StringArgumentType.getString(context, "identifier") 83 + 84 + context.source.sendSuccess( 85 + { Component.literal("§eVerifying AT Protocol identity...") }, 86 + false 87 + ) 88 + 89 + coroutineScope.launch { 90 + try { 91 + // Resolve the identifier (handle or DID) to get DID, handle, and PDS 92 + val (did, handle, pdsUrl) = client.resolveIdentifier(identifier).getOrThrow() 93 + 94 + // Verify the identity exists by fetching the profile 95 + val profile = client.getProfile(did, pdsUrl).getOrThrow() 96 + 97 + // Link the identity 98 + identityStore.linkIdentity(player.uuid, profile.did, profile.handle) 99 + 100 + player.sendSystemMessage( 101 + Component.literal("§a✓ Successfully linked to AT Protocol!") 102 + .append(Component.literal("\n§7Handle: §f${profile.handle}")) 103 + .append(Component.literal("\n§7DID: §f${profile.did}")) 104 + .append(Component.literal("\n§7PDS: §f$pdsUrl")) 105 + .apply { 106 + profile.displayName?.let { 107 + append(Component.literal("\n§7Display Name: §f$it")) 108 + } 109 + } 110 + .append(Component.literal("\n\n§eNote: Use §f/atproto login§e to authenticate and sync data")) 111 + ) 112 + 113 + logger.info("Player ${player.name.string} (${player.uuid}) linked to ${profile.handle}") 114 + } catch (e: Exception) { 115 + player.sendSystemMessage( 116 + Component.literal("§c✗ Failed to link AT Protocol identity") 117 + .append(Component.literal("\n§7${e.message ?: "Unknown error"}")) 118 + ) 119 + logger.error("Failed to link identity for player ${player.name.string}", e) 120 + } 121 + } 122 + 123 + return 1 124 + } 125 + 126 + /** 127 + * Unlinks a player's Minecraft UUID from their AT Protocol identity. 128 + */ 129 + private fun unlinkIdentity(context: CommandContext<CommandSourceStack>): Int { 130 + val player = context.source.playerOrException 131 + val identity = identityStore.getIdentity(player.uuid) 132 + 133 + return if (identity != null) { 134 + // Also logout if they're authenticated 135 + if (sessionManager.hasSession(player.uuid)) { 136 + sessionManager.deleteSession(player.uuid) 137 + } 138 + 139 + identityStore.unlinkIdentity(player.uuid) 140 + context.source.sendSuccess( 141 + { 142 + Component.literal("§a✓ Unlinked from AT Protocol identity") 143 + .append(Component.literal("\n§7Previously linked to: §f${identity.handle}")) 144 + }, 145 + false 146 + ) 147 + logger.info("Player ${player.name.string} (${player.uuid}) unlinked from ${identity.handle}") 148 + 1 149 + } else { 150 + context.source.sendFailure( 151 + Component.literal("§c✗ You don't have a linked AT Protocol identity") 152 + ) 153 + 0 154 + } 155 + } 156 + 157 + /** 158 + * Authenticates a player with their AT Protocol credentials. 159 + * Uses app passwords for security. 160 + */ 161 + private fun login(context: CommandContext<CommandSourceStack>): Int { 162 + val player = context.source.playerOrException 163 + val identifier = StringArgumentType.getString(context, "identifier") 164 + val password = StringArgumentType.getString(context, "password") 165 + 166 + context.source.sendSuccess( 167 + { Component.literal("§eAuthenticating with AT Protocol...") }, 168 + false 169 + ) 170 + 171 + coroutineScope.launch { 172 + try { 173 + // Create session 174 + val session = sessionManager.createSession(player.uuid, identifier, password).getOrThrow() 175 + 176 + // Link identity if not already linked 177 + if (!identityStore.isLinked(player.uuid)) { 178 + identityStore.linkIdentity(player.uuid, session.did, session.handle) 179 + } 180 + 181 + player.sendSystemMessage( 182 + Component.literal("§a✓ Successfully authenticated!") 183 + .append(Component.literal("\n§7Handle: §f${session.handle}")) 184 + .append(Component.literal("\n§7DID: §f${session.did}")) 185 + .append(Component.literal("\n§7PDS: §f${session.pdsUrl}")) 186 + .append(Component.literal("\n\n§aYou can now sync your Minecraft data to AT Protocol!")) 187 + ) 188 + 189 + logger.info("Player ${player.name.string} (${player.uuid}) authenticated as ${session.handle}") 190 + } catch (e: Exception) { 191 + player.sendSystemMessage( 192 + Component.literal("§c✗ Authentication failed") 193 + .append(Component.literal("\n§7${e.message ?: "Unknown error"}")) 194 + .append(Component.literal("\n\n§7Tip: Use an §fApp Password§7 from your AT Protocol account settings")) 195 + .append(Component.literal("\n§7Never use your main account password!")) 196 + ) 197 + logger.error("Failed to authenticate player ${player.name.string}", e) 198 + } 199 + } 200 + 201 + return 1 202 + } 203 + 204 + /** 205 + * Logs out a player (removes their authentication session). 206 + */ 207 + private fun logout(context: CommandContext<CommandSourceStack>): Int { 208 + val player = context.source.playerOrException 209 + 210 + return if (sessionManager.hasSession(player.uuid)) { 211 + sessionManager.deleteSession(player.uuid) 212 + context.source.sendSuccess( 213 + { 214 + Component.literal("§a✓ Logged out successfully") 215 + .append(Component.literal("\n§7Your identity link remains active")) 216 + .append(Component.literal("\n§7Use §f/atproto login§7 to authenticate again")) 217 + }, 218 + false 219 + ) 220 + logger.info("Player ${player.name.string} (${player.uuid}) logged out") 221 + 1 222 + } else { 223 + context.source.sendFailure( 224 + Component.literal("§c✗ You are not logged in") 225 + ) 226 + 0 227 + } 228 + } 229 + 230 + /** 231 + * Shows the player their own linked AT Protocol identity and authentication status. 232 + */ 233 + private fun whoami(context: CommandContext<CommandSourceStack>): Int { 234 + val player = context.source.playerOrException 235 + val identity = identityStore.getIdentity(player.uuid) 236 + 237 + return if (identity != null) { 238 + val linkedAgo = formatTimeSince(identity.linkedAt) 239 + val verifiedAgo = formatTimeSince(identity.lastVerified) 240 + val isAuthenticated = sessionManager.hasSession(player.uuid) 241 + 242 + context.source.sendSuccess( 243 + { 244 + Component.literal("§b━━━ Your AT Protocol Identity ━━━") 245 + .append(Component.literal("\n§7Handle: §f${identity.handle}")) 246 + .append(Component.literal("\n§7DID: §f${identity.did}")) 247 + .append(Component.literal("\n§7Linked: §f$linkedAgo ago")) 248 + .append(Component.literal("\n§7Last Verified: §f$verifiedAgo ago")) 249 + .append(Component.literal("\n")) 250 + .append( 251 + if (isAuthenticated) { 252 + Component.literal("\n§aAuthentication: §f✓ Active") 253 + .append(Component.literal("\n§7You can sync data to AT Protocol")) 254 + } else { 255 + Component.literal("\n§cAuthentication: §f✗ Not logged in") 256 + .append(Component.literal("\n§7Use §f/atproto login§7 to authenticate")) 257 + } 258 + ) 259 + }, 260 + false 261 + ) 262 + 1 263 + } else { 264 + context.source.sendFailure( 265 + Component.literal("§c✗ You don't have a linked AT Protocol identity") 266 + .append(Component.literal("\n§7Use §f/atproto link <handle or DID>§7 to link your identity")) 267 + ) 268 + 0 269 + } 270 + } 271 + 272 + /** 273 + * Shows information about another player's AT Protocol identity. 274 + */ 275 + private fun whois(context: CommandContext<CommandSourceStack>): Int { 276 + val identifier = StringArgumentType.getString(context, "identifier") 277 + 278 + coroutineScope.launch { 279 + try { 280 + val player = context.source.playerOrException 281 + 282 + // Try to find by Minecraft username first 283 + val minecraftPlayer = context.source.server.playerList.players 284 + .firstOrNull { it.name.string.equals(identifier, ignoreCase = true) } 285 + 286 + val identity = if (minecraftPlayer != null) { 287 + identityStore.getIdentity(minecraftPlayer.uuid) 288 + } else { 289 + // Try as AT Protocol handle or DID 290 + val uuid = identityStore.getUuidByHandle(identifier) 291 + ?: identityStore.getUuidByDid(identifier) 292 + uuid?.let { identityStore.getIdentity(it) } 293 + } 294 + 295 + if (identity != null) { 296 + val linkedAgo = formatTimeSince(identity.linkedAt) 297 + player.sendSystemMessage( 298 + Component.literal("§b━━━ AT Protocol Identity ━━━") 299 + .append(Component.literal("\n§7Handle: §f${identity.handle}")) 300 + .append(Component.literal("\n§7DID: §f${identity.did}")) 301 + .append(Component.literal("\n§7Linked: §f$linkedAgo ago")) 302 + ) 303 + } else { 304 + player.sendSystemMessage( 305 + Component.literal("§c✗ No linked AT Protocol identity found for: $identifier") 306 + ) 307 + } 308 + } catch (e: Exception) { 309 + logger.error("Error in whois command", e) 310 + } 311 + } 312 + 313 + return 1 314 + } 315 + 316 + /** 317 + * Shows authentication and connection status. 318 + */ 319 + private fun status(context: CommandContext<CommandSourceStack>): Int { 320 + val player = context.source.playerOrException 321 + val isLinked = identityStore.isLinked(player.uuid) 322 + val isAuthenticated = sessionManager.hasSession(player.uuid) 323 + 324 + context.source.sendSuccess( 325 + { 326 + Component.literal("§b━━━ AT Protocol Status ━━━") 327 + .append( 328 + if (isLinked) { 329 + val identity = identityStore.getIdentity(player.uuid)!! 330 + Component.literal("\n§aIdentity: §f✓ Linked to ${identity.handle}") 331 + } else { 332 + Component.literal("\n§cIdentity: §f✗ Not linked") 333 + } 334 + ) 335 + .append( 336 + if (isAuthenticated) { 337 + Component.literal("\n§aAuthentication: §f✓ Active session") 338 + } else { 339 + Component.literal("\n§cAuthentication: §f✗ Not logged in") 340 + } 341 + ) 342 + .append( 343 + if (isLinked && isAuthenticated) { 344 + Component.literal("\n\n§aReady to sync Minecraft data to AT Protocol!") 345 + } else if (isLinked) { 346 + Component.literal("\n\n§eUse §f/atproto login§e to authenticate") 347 + } else { 348 + Component.literal("\n\n§eUse §f/atproto link <handle>§e to get started") 349 + } 350 + ) 351 + }, 352 + false 353 + ) 354 + return 1 355 + } 356 + 357 + /** 358 + * Shows help information for AT Protocol commands. 359 + */ 360 + private fun help(context: CommandContext<CommandSourceStack>): Int { 361 + context.source.sendSuccess( 362 + { 363 + Component.literal("§b━━━ AT Protocol Commands ━━━") 364 + .append(Component.literal("\n§f/atproto link <handle or DID>")) 365 + .append(Component.literal("\n §7Link your Minecraft account to your AT Protocol identity")) 366 + .append(Component.literal("\n §7Example: §f/atproto link alice.bsky.social")) 367 + .append(Component.literal("\n")) 368 + .append(Component.literal("\n§f/atproto login <handle> <app-password>")) 369 + .append(Component.literal("\n §7Authenticate to enable data syncing")) 370 + .append(Component.literal("\n §7§cUse an App Password, not your main password!")) 371 + .append(Component.literal("\n §7Get one from: Settings → App Passwords")) 372 + .append(Component.literal("\n")) 373 + .append(Component.literal("\n§f/atproto logout")) 374 + .append(Component.literal("\n §7Log out (removes authentication, keeps identity link)")) 375 + .append(Component.literal("\n")) 376 + .append(Component.literal("\n§f/atproto unlink")) 377 + .append(Component.literal("\n §7Unlink your AT Protocol identity completely")) 378 + .append(Component.literal("\n")) 379 + .append(Component.literal("\n§f/atproto whoami")) 380 + .append(Component.literal("\n §7View your linked identity and authentication status")) 381 + .append(Component.literal("\n")) 382 + .append(Component.literal("\n§f/atproto status")) 383 + .append(Component.literal("\n §7Check connection status")) 384 + .append(Component.literal("\n")) 385 + .append(Component.literal("\n§f/atproto whois <player or handle>")) 386 + .append(Component.literal("\n §7Look up another player's AT Protocol identity")) 387 + }, 388 + false 389 + ) 390 + return 1 391 + } 392 + 393 + /** 394 + * Formats a timestamp into a human-readable "time since" string. 395 + */ 396 + private fun formatTimeSince(timestamp: Long): String { 397 + val seconds = (System.currentTimeMillis() - timestamp) / 1000 398 + return when { 399 + seconds < 60 -> "$seconds seconds" 400 + seconds < 3600 -> "${seconds / 60} minutes" 401 + seconds < 86400 -> "${seconds / 3600} hours" 402 + else -> "${seconds / 86400} days" 403 + } 404 + } 405 + }
+228
src/main/kotlin/com/jollywhoppers/atproto/AtProtoSessionManager.kt
··· 1 + package com.jollywhoppers.atproto 2 + 3 + import kotlinx.serialization.Serializable 4 + import kotlinx.serialization.json.Json 5 + import kotlinx.serialization.encodeToString 6 + import org.slf4j.LoggerFactory 7 + import java.nio.file.Files 8 + import java.nio.file.Path 9 + import java.nio.file.StandardOpenOption 10 + import java.util.* 11 + import java.util.concurrent.ConcurrentHashMap 12 + 13 + /** 14 + * Manages AT Protocol authentication sessions for players. 15 + * Handles token storage, refresh, and session lifecycle. 16 + */ 17 + class AtProtoSessionManager( 18 + private val storageFile: Path, 19 + private val client: AtProtoClient 20 + ) { 21 + private val logger = LoggerFactory.getLogger("atproto-connect") 22 + private val sessions = ConcurrentHashMap<UUID, PlayerSession>() 23 + 24 + private val json = Json { 25 + prettyPrint = true 26 + ignoreUnknownKeys = true 27 + } 28 + 29 + @Serializable 30 + data class PlayerSession( 31 + val uuid: String, 32 + val did: String, 33 + val handle: String, 34 + val pdsUrl: String, 35 + val accessJwt: String, 36 + val refreshJwt: String, 37 + val createdAt: Long = System.currentTimeMillis(), 38 + val lastRefreshed: Long = System.currentTimeMillis() 39 + ) 40 + 41 + @Serializable 42 + private data class SessionStorage( 43 + val version: Int = 1, 44 + val sessions: List<PlayerSession> 45 + ) 46 + 47 + init { 48 + load() 49 + } 50 + 51 + /** 52 + * Creates or updates a session for a player. 53 + */ 54 + suspend fun createSession( 55 + uuid: UUID, 56 + identifier: String, 57 + password: String 58 + ): Result<PlayerSession> = runCatching { 59 + logger.info("Creating session for player $uuid with identifier $identifier") 60 + 61 + // Create the session via AT Protocol 62 + val sessionResponse = client.createSession(identifier, password).getOrThrow() 63 + 64 + // Resolve to get PDS URL 65 + val (did, handle, pdsUrl) = client.resolveIdentifier(sessionResponse.did).getOrThrow() 66 + 67 + val session = PlayerSession( 68 + uuid = uuid.toString(), 69 + did = did, 70 + handle = handle, 71 + pdsUrl = pdsUrl, 72 + accessJwt = sessionResponse.accessJwt, 73 + refreshJwt = sessionResponse.refreshJwt, 74 + createdAt = System.currentTimeMillis(), 75 + lastRefreshed = System.currentTimeMillis() 76 + ) 77 + 78 + sessions[uuid] = session 79 + save() 80 + 81 + logger.info("Session created successfully for $handle") 82 + session 83 + } 84 + 85 + /** 86 + * Gets the active session for a player. 87 + * Automatically refreshes if the access token is expired. 88 + */ 89 + suspend fun getSession(uuid: UUID): Result<PlayerSession> = runCatching { 90 + val session = sessions[uuid] 91 + ?: throw Exception("No session found for player") 92 + 93 + // Check if session needs refresh (access tokens typically expire after 2 hours) 94 + // We'll refresh if it's been more than 1.5 hours to be safe 95 + val hoursSinceRefresh = (System.currentTimeMillis() - session.lastRefreshed) / (1000 * 60 * 60) 96 + 97 + if (hoursSinceRefresh >= 1.5) { 98 + logger.info("Session for ${session.handle} needs refresh") 99 + return refreshSession(uuid) 100 + } 101 + 102 + session 103 + } 104 + 105 + /** 106 + * Refreshes a player's session using their refresh token. 107 + */ 108 + suspend fun refreshSession(uuid: UUID): Result<PlayerSession> = runCatching { 109 + val oldSession = sessions[uuid] 110 + ?: throw Exception("No session found for player") 111 + 112 + logger.info("Refreshing session for ${oldSession.handle}") 113 + 114 + val refreshResponse = client.refreshSession( 115 + oldSession.refreshJwt, 116 + oldSession.pdsUrl 117 + ).getOrThrow() 118 + 119 + val newSession = oldSession.copy( 120 + accessJwt = refreshResponse.accessJwt, 121 + refreshJwt = refreshResponse.refreshJwt, 122 + lastRefreshed = System.currentTimeMillis() 123 + ) 124 + 125 + sessions[uuid] = newSession 126 + save() 127 + 128 + logger.info("Session refreshed successfully for ${oldSession.handle}") 129 + newSession 130 + } 131 + 132 + /** 133 + * Removes a player's session (logout). 134 + */ 135 + fun deleteSession(uuid: UUID): Boolean { 136 + val removed = sessions.remove(uuid) 137 + if (removed != null) { 138 + save() 139 + logger.info("Session deleted for ${removed.handle}") 140 + return true 141 + } 142 + return false 143 + } 144 + 145 + /** 146 + * Checks if a player has an active session. 147 + */ 148 + fun hasSession(uuid: UUID): Boolean { 149 + return sessions.containsKey(uuid) 150 + } 151 + 152 + /** 153 + * Gets all active sessions. 154 + */ 155 + fun getAllSessions(): Map<UUID, PlayerSession> { 156 + return sessions.toMap() 157 + } 158 + 159 + /** 160 + * Makes an authenticated XRPC request for a player. 161 + * Automatically refreshes the session if needed. 162 + */ 163 + suspend fun makeAuthenticatedRequest( 164 + uuid: UUID, 165 + method: String, 166 + endpoint: String, 167 + body: String? = null 168 + ): Result<String> = runCatching { 169 + val session = getSession(uuid).getOrThrow() 170 + 171 + client.xrpcRequest( 172 + method = method, 173 + endpoint = endpoint, 174 + accessJwt = session.accessJwt, 175 + pdsUrl = session.pdsUrl, 176 + body = body 177 + ).getOrThrow() 178 + } 179 + 180 + /** 181 + * Loads sessions from disk. 182 + */ 183 + private fun load() { 184 + try { 185 + if (Files.exists(storageFile)) { 186 + val content = Files.readString(storageFile) 187 + val storage = json.decodeFromString<SessionStorage>(content) 188 + 189 + storage.sessions.forEach { session -> 190 + val uuid = UUID.fromString(session.uuid) 191 + sessions[uuid] = session 192 + } 193 + 194 + logger.info("Loaded ${sessions.size} sessions from disk") 195 + } else { 196 + logger.info("No existing session storage found, starting fresh") 197 + } 198 + } catch (e: Exception) { 199 + logger.error("Failed to load sessions", e) 200 + } 201 + } 202 + 203 + /** 204 + * Saves sessions to disk. 205 + */ 206 + private fun save() { 207 + try { 208 + Files.createDirectories(storageFile.parent) 209 + 210 + val storage = SessionStorage( 211 + version = 1, 212 + sessions = sessions.values.toList() 213 + ) 214 + 215 + val content = json.encodeToString(storage) 216 + Files.writeString( 217 + storageFile, 218 + content, 219 + StandardOpenOption.CREATE, 220 + StandardOpenOption.TRUNCATE_EXISTING 221 + ) 222 + 223 + logger.debug("Saved ${sessions.size} sessions to disk") 224 + } catch (e: Exception) { 225 + logger.error("Failed to save sessions", e) 226 + } 227 + } 228 + }
+175
src/main/kotlin/com/jollywhoppers/atproto/PlayerIdentityStore.kt
··· 1 + package com.jollywhoppers.atproto 2 + 3 + import kotlinx.serialization.Serializable 4 + import kotlinx.serialization.json.Json 5 + import kotlinx.serialization.encodeToString 6 + import org.slf4j.LoggerFactory 7 + import java.nio.file.Files 8 + import java.nio.file.Path 9 + import java.nio.file.StandardOpenOption 10 + import java.util.* 11 + import java.util.concurrent.ConcurrentHashMap 12 + 13 + /** 14 + * Manages the mapping between Minecraft UUIDs and AT Protocol DIDs. 15 + * Handles persistence to disk and in-memory caching. 16 + */ 17 + class PlayerIdentityStore(private val storageFile: Path) { 18 + private val logger = LoggerFactory.getLogger("atproto-connect") 19 + private val identities = ConcurrentHashMap<UUID, PlayerIdentity>() 20 + 21 + private val json = Json { 22 + prettyPrint = true 23 + ignoreUnknownKeys = true 24 + } 25 + 26 + @Serializable 27 + data class PlayerIdentity( 28 + val uuid: String, 29 + val did: String, 30 + val handle: String, 31 + val linkedAt: Long = System.currentTimeMillis(), 32 + val lastVerified: Long = System.currentTimeMillis() 33 + ) 34 + 35 + @Serializable 36 + private data class IdentityStorage( 37 + val version: Int = 1, 38 + val identities: List<PlayerIdentity> 39 + ) 40 + 41 + init { 42 + load() 43 + } 44 + 45 + /** 46 + * Links a Minecraft player UUID to an AT Protocol DID. 47 + */ 48 + fun linkIdentity(uuid: UUID, did: String, handle: String): PlayerIdentity { 49 + val identity = PlayerIdentity( 50 + uuid = uuid.toString(), 51 + did = did, 52 + handle = handle, 53 + linkedAt = System.currentTimeMillis(), 54 + lastVerified = System.currentTimeMillis() 55 + ) 56 + 57 + identities[uuid] = identity 58 + save() 59 + 60 + logger.info("Linked player $uuid to AT Protocol identity $handle ($did)") 61 + return identity 62 + } 63 + 64 + /** 65 + * Removes the link between a Minecraft player UUID and their AT Protocol DID. 66 + */ 67 + fun unlinkIdentity(uuid: UUID): Boolean { 68 + val removed = identities.remove(uuid) 69 + if (removed != null) { 70 + save() 71 + logger.info("Unlinked player $uuid from AT Protocol identity ${removed.handle}") 72 + return true 73 + } 74 + return false 75 + } 76 + 77 + /** 78 + * Gets the AT Protocol identity for a Minecraft player UUID. 79 + */ 80 + fun getIdentity(uuid: UUID): PlayerIdentity? { 81 + return identities[uuid] 82 + } 83 + 84 + /** 85 + * Gets the Minecraft UUID for an AT Protocol DID. 86 + */ 87 + fun getUuidByDid(did: String): UUID? { 88 + return identities.entries 89 + .firstOrNull { it.value.did == did } 90 + ?.key 91 + } 92 + 93 + /** 94 + * Gets the Minecraft UUID for an AT Protocol handle. 95 + */ 96 + fun getUuidByHandle(handle: String): UUID? { 97 + return identities.entries 98 + .firstOrNull { it.value.handle.equals(handle, ignoreCase = true) } 99 + ?.key 100 + } 101 + 102 + /** 103 + * Checks if a Minecraft player UUID is linked to an AT Protocol identity. 104 + */ 105 + fun isLinked(uuid: UUID): Boolean { 106 + return identities.containsKey(uuid) 107 + } 108 + 109 + /** 110 + * Gets all linked identities. 111 + */ 112 + fun getAllIdentities(): Map<UUID, PlayerIdentity> { 113 + return identities.toMap() 114 + } 115 + 116 + /** 117 + * Updates the last verified timestamp for a player's identity. 118 + */ 119 + fun updateVerification(uuid: UUID) { 120 + identities[uuid]?.let { identity -> 121 + val updated = identity.copy(lastVerified = System.currentTimeMillis()) 122 + identities[uuid] = updated 123 + save() 124 + } 125 + } 126 + 127 + /** 128 + * Loads identities from disk. 129 + */ 130 + private fun load() { 131 + try { 132 + if (Files.exists(storageFile)) { 133 + val content = Files.readString(storageFile) 134 + val storage = json.decodeFromString<IdentityStorage>(content) 135 + 136 + storage.identities.forEach { identity -> 137 + val uuid = UUID.fromString(identity.uuid) 138 + identities[uuid] = identity 139 + } 140 + 141 + logger.info("Loaded ${identities.size} player identities from disk") 142 + } else { 143 + logger.info("No existing identity storage found, starting fresh") 144 + } 145 + } catch (e: Exception) { 146 + logger.error("Failed to load player identities", e) 147 + } 148 + } 149 + 150 + /** 151 + * Saves identities to disk. 152 + */ 153 + private fun save() { 154 + try { 155 + Files.createDirectories(storageFile.parent) 156 + 157 + val storage = IdentityStorage( 158 + version = 1, 159 + identities = identities.values.toList() 160 + ) 161 + 162 + val content = json.encodeToString(storage) 163 + Files.writeString( 164 + storageFile, 165 + content, 166 + StandardOpenOption.CREATE, 167 + StandardOpenOption.TRUNCATE_EXISTING 168 + ) 169 + 170 + logger.debug("Saved ${identities.size} player identities to disk") 171 + } catch (e: Exception) { 172 + logger.error("Failed to save player identities", e) 173 + } 174 + } 175 + }
+271
src/main/kotlin/com/jollywhoppers/atproto/README.md
··· 1 + # AT Protocol Integration 2 + 3 + This package contains the core AT Protocol integration for atproto-connect, enabling Minecraft players to link their game accounts to their AT Protocol identities and authenticate to sync data. 4 + 5 + ## Components 6 + 7 + ### AtProtoClient.kt 8 + Enhanced HTTP client for interacting with AT Protocol services. Provides methods for: 9 + 10 + - **Slingshot Integration**: Uses [Slingshot](https://slingshot.microcosm.blue) for fast, cached PDS resolution via `resolveMiniDoc` 11 + - **Handle Resolution**: Convert AT Protocol handles (e.g., `alice.bsky.social`) to DIDs 12 + - **DID Resolution**: Resolve DIDs to DID Documents (supports `did:plc` and `did:web`) 13 + - **Profile Retrieval**: Fetch user profiles from the AT Protocol network 14 + - **Session Management**: Create and refresh authenticated sessions 15 + - **XRPC Requests**: Make authenticated API calls to PDS instances 16 + 17 + The client uses Java's built-in `HttpClient` for HTTP requests and `kotlinx.serialization` for JSON parsing. It automatically falls back to standard resolution methods if Slingshot is unavailable. 18 + 19 + ### PlayerIdentityStore.kt 20 + Manages the persistent mapping between Minecraft UUIDs and AT Protocol DIDs. Features: 21 + 22 + - **In-Memory Cache**: Uses `ConcurrentHashMap` for fast lookups 23 + - **Disk Persistence**: Stores identities in JSON format 24 + - **Bidirectional Lookup**: Find UUID by DID/handle, or DID/handle by UUID 25 + - **Verification Tracking**: Records when identities were linked and last verified 26 + 27 + The storage file is located at `config/atproto-connect/player-identities.json`. 28 + 29 + ### AtProtoSessionManager.kt 30 + Manages authenticated AT Protocol sessions for players. Features: 31 + 32 + - **Token Storage**: Securely stores access and refresh tokens 33 + - **Automatic Refresh**: Refreshes access tokens before expiration 34 + - **Session Lifecycle**: Handles login, logout, and token management 35 + - **Authenticated Requests**: Provides helper methods for making authenticated XRPC calls 36 + 37 + Sessions are stored at `config/atproto-connect/player-sessions.json`. 38 + 39 + ### AtProtoCommands.kt 40 + Brigadier command handler providing in-game commands: 41 + 42 + #### `/atproto link <handle or DID>` 43 + Links the player's Minecraft UUID to their AT Protocol identity (no authentication required). 44 + - Accepts either a handle (`alice.bsky.social`) or DID (`did:plc:...`) 45 + - Validates the identity exists on the AT Protocol network 46 + - Resolves PDS URL via Slingshot 47 + - Stores the mapping for future use 48 + 49 + **Example:** 50 + ``` 51 + /atproto link alice.bsky.social 52 + ✓ Successfully linked to AT Protocol! 53 + Handle: alice.bsky.social 54 + DID: did:plc:abcdef123456 55 + PDS: https://morel.us-east.host.bsky.network 56 + ``` 57 + 58 + #### `/atproto login <handle> <app-password>` 59 + Authenticates the player to enable data syncing. 60 + - **IMPORTANT**: Always use an App Password, never your main account password! 61 + - Creates an authenticated session with the player's PDS 62 + - Stores access and refresh tokens securely 63 + - Enables stat syncing and record creation 64 + 65 + **Getting an App Password:** 66 + 1. Go to your AT Protocol account settings 67 + 2. Navigate to App Passwords 68 + 3. Create a new app password with a descriptive name (e.g., "Minecraft Server") 69 + 4. Copy the password immediately (you won't see it again!) 70 + 5. Use it in the login command 71 + 72 + **Example:** 73 + ``` 74 + /atproto login alice.bsky.social abcd-1234-efgh-5678 75 + ✓ Successfully authenticated! 76 + Handle: alice.bsky.social 77 + DID: did:plc:abcdef123456 78 + PDS: https://morel.us-east.host.bsky.network 79 + 80 + You can now sync your Minecraft data to AT Protocol! 81 + ``` 82 + 83 + #### `/atproto logout` 84 + Removes the player's authenticated session (but keeps their identity link). 85 + 86 + #### `/atproto unlink` 87 + Removes both the identity link and any authenticated session. 88 + 89 + #### `/atproto whoami` 90 + Displays the player's linked AT Protocol identity and authentication status. 91 + 92 + #### `/atproto status` 93 + Shows a quick overview of identity and authentication status. 94 + 95 + #### `/atproto whois <player or handle>` 96 + Looks up another player's AT Protocol identity. 97 + 98 + ## Architecture 99 + 100 + ``` 101 + Player Command 102 + 103 + AtProtoCommands (Coroutine Scope) 104 + 105 + ┌─────────────────────────────┐ 106 + │ AtProtoSessionManager │ 107 + │ (Token Management) │ 108 + └─────────────────────────────┘ 109 + 110 + ┌─────────────────────────────┐ 111 + │ AtProtoClient │ 112 + │ (HTTP + Slingshot) │ 113 + └─────────────────────────────┘ 114 + 115 + ┌─────────────────────────────┐ 116 + │ Slingshot (Microcosm) │ 117 + │ PDS Resolution & Caching │ 118 + └─────────────────────────────┘ 119 + 120 + AT Protocol Network 121 + - Player's PDS (Authentication) 122 + - plc.directory (DID Resolution) 123 + 124 + PlayerIdentityStore (Persistence) 125 + ``` 126 + 127 + ## PDS Resolution with Slingshot 128 + 129 + The mod uses [Slingshot](https://slingshot.microcosm.blue) for identity resolution, which provides: 130 + 131 + 1. **Fast Resolution**: Pre-cached PDS endpoints for quick lookups 132 + 2. **resolveMiniDoc**: Returns `{did, handle, pds, pdsKnown}` in one call 133 + 3. **Reliability**: Automatically falls back to standard resolution methods 134 + 4. **Bi-directional Verification**: Only returns verified handle↔DID mappings 135 + 136 + **Example resolveMiniDoc response:** 137 + ```json 138 + { 139 + "did": "did:plc:abcdef123456", 140 + "handle": "alice.bsky.social", 141 + "pds": "https://morel.us-east.host.bsky.network", 142 + "pdsKnown": true 143 + } 144 + ``` 145 + 146 + ## Authentication Flow 147 + 148 + ### 1. Initial Setup (Link) 149 + ``` 150 + Player runs: /atproto link alice.bsky.social 151 + 152 + Resolve via Slingshot → Get (DID, handle, PDS) 153 + 154 + Fetch profile to verify identity exists 155 + 156 + Store UUID ↔ (DID, handle) mapping 157 + ``` 158 + 159 + ### 2. Authentication (Login) 160 + ``` 161 + Player runs: /atproto login alice.bsky.social abcd-1234-efgh-5678 162 + 163 + Resolve PDS URL via Slingshot 164 + 165 + POST /xrpc/com.atproto.server.createSession 166 + Body: {identifier, password} 167 + 168 + Response: {did, handle, accessJwt, refreshJwt} 169 + 170 + Store session with tokens 171 + ``` 172 + 173 + ### 3. Making Authenticated Requests 174 + ``` 175 + Mod needs to sync stats 176 + 177 + sessionManager.makeAuthenticatedRequest(uuid, "POST", "com.atproto.repo.createRecord", body) 178 + 179 + Check if access token needs refresh (>1.5 hours old) 180 + ↓ (if needed) 181 + POST /xrpc/com.atproto.server.refreshSession 182 + Header: Authorization: Bearer {refreshJwt} 183 + 184 + Update session with new tokens 185 + 186 + Make actual request with current accessJwt 187 + ``` 188 + 189 + ## Data Storage 190 + 191 + ### Identity Storage (`player-identities.json`) 192 + ```json 193 + { 194 + "version": 1, 195 + "identities": [ 196 + { 197 + "uuid": "550e8400-e29b-41d4-a716-446655440000", 198 + "did": "did:plc:abcdef123456", 199 + "handle": "alice.bsky.social", 200 + "linkedAt": 1703001600000, 201 + "lastVerified": 1703001600000 202 + } 203 + ] 204 + } 205 + ``` 206 + 207 + ### Session Storage (`player-sessions.json`) 208 + ```json 209 + { 210 + "version": 1, 211 + "sessions": [ 212 + { 213 + "uuid": "550e8400-e29b-41d4-a716-446655440000", 214 + "did": "did:plc:abcdef123456", 215 + "handle": "alice.bsky.social", 216 + "pdsUrl": "https://morel.us-east.host.bsky.network", 217 + "accessJwt": "eyJ...", 218 + "refreshJwt": "eyJ...", 219 + "createdAt": 1703001600000, 220 + "lastRefreshed": 1703001600000 221 + } 222 + ] 223 + } 224 + ``` 225 + 226 + ## Security Considerations 227 + 228 + - **App Passwords Only**: Players should NEVER use their main account password 229 + - **Token Storage**: Tokens are stored on disk - server operators should secure the config directory 230 + - **Automatic Refresh**: Access tokens are refreshed automatically before expiration 231 + - **No Credential Storage**: App passwords are never stored, only the resulting JWT tokens 232 + - **Per-PDS**: Each player authenticates with their own PDS, maintaining decentralization 233 + 234 + ## Error Handling 235 + 236 + All AT Protocol operations use Kotlin's `Result` type for error handling: 237 + - Network failures → Friendly error messages to players 238 + - Invalid identifiers → Format validation with helpful feedback 239 + - Authentication errors → Clear guidance about app passwords 240 + - Token expiration → Automatic refresh or re-login prompt 241 + 242 + Commands run in coroutine scope (`Dispatchers.IO`) to avoid blocking the server thread. 243 + 244 + ## Creating App Passwords 245 + 246 + Players need to create an app password to authenticate: 247 + 248 + 1. **Bluesky Users**: 249 + - Go to Settings → Privacy and Security → App Passwords 250 + - Click "Add App Password" 251 + - Name it something descriptive (e.g., "Minecraft Server Name") 252 + - Copy the password immediately 253 + 254 + 2. **Other PDS Providers**: 255 + - Check your PDS provider's documentation for app password creation 256 + - The process is similar but may be in different locations 257 + 258 + 3. **Security Tips**: 259 + - Use a unique app password for each Minecraft server 260 + - Never share your app password 261 + - Revoke app passwords you're no longer using 262 + - If compromised, revoke immediately and create a new one 263 + 264 + ## Future Enhancements 265 + 266 + - OAuth device flow for better security (no passwords in chat) 267 + - Automatic session refresh on server restart 268 + - DPoP (Demonstrating Proof of Possession) support 269 + - Support for custom PDS instances with different auth flows 270 + - Integration with the lexicon records for automatic stat syncing 271 + - Web-based authentication portal
+248
src/main/kotlin/com/jollywhoppers/atproto/examples/RecordCreationExample.kt
··· 1 + package com.jollywhoppers.atproto.examples 2 + 3 + import com.jollywhoppers.atproto.AtProtoSessionManager 4 + import kotlinx.serialization.Serializable 5 + import kotlinx.serialization.encodeToString 6 + import kotlinx.serialization.json.Json 7 + import java.util.* 8 + 9 + /** 10 + * Example showing how to create records in a player's AT Protocol repository. 11 + * This demonstrates the foundation for syncing Minecraft data to AT Protocol. 12 + */ 13 + class RecordCreationExample( 14 + private val sessionManager: AtProtoSessionManager 15 + ) { 16 + private val json = Json { prettyPrint = false } 17 + 18 + /** 19 + * Example: Create a player stats record 20 + */ 21 + suspend fun createPlayerStatsRecord( 22 + playerUuid: UUID, 23 + statistics: List<Statistic>, 24 + playtimeMinutes: Int, 25 + level: Int 26 + ): Result<String> = runCatching { 27 + // Get the player's session (will auto-refresh if needed) 28 + val session = sessionManager.getSession(playerUuid).getOrThrow() 29 + 30 + // Build the record according to our lexicon 31 + val record = PlayerStatsRecord( 32 + `$type` = "com.jollywhoppers.minecraft.player.stats", 33 + player = PlayerReference( 34 + uuid = playerUuid.toString(), 35 + username = "ExamplePlayer" // Would come from Minecraft player object 36 + ), 37 + server = ServerReference( 38 + serverId = "example-server-id", 39 + serverName = "Example Server" 40 + ), 41 + statistics = statistics, 42 + playtimeMinutes = playtimeMinutes, 43 + level = level, 44 + gamemode = "survival", 45 + syncedAt = java.time.Instant.now().toString() 46 + ) 47 + 48 + // Create the record via XRPC 49 + val requestBody = CreateRecordRequest( 50 + repo = session.did, 51 + collection = "com.jollywhoppers.minecraft.player.stats", 52 + record = record 53 + ) 54 + 55 + val response = sessionManager.makeAuthenticatedRequest( 56 + uuid = playerUuid, 57 + method = "POST", 58 + endpoint = "com.atproto.repo.createRecord", 59 + body = json.encodeToString(requestBody) 60 + ).getOrThrow() 61 + 62 + response 63 + } 64 + 65 + /** 66 + * Example: Create a player profile record 67 + */ 68 + suspend fun createPlayerProfileRecord( 69 + playerUuid: UUID, 70 + displayName: String?, 71 + bio: String?, 72 + publicStats: Boolean = true 73 + ): Result<String> = runCatching { 74 + val session = sessionManager.getSession(playerUuid).getOrThrow() 75 + 76 + val record = PlayerProfileRecord( 77 + `$type` = "com.jollywhoppers.minecraft.player.profile", 78 + player = PlayerReference( 79 + uuid = playerUuid.toString(), 80 + username = "ExamplePlayer" 81 + ), 82 + displayName = displayName, 83 + bio = bio, 84 + createdAt = java.time.Instant.now().toString(), 85 + publicStats = publicStats, 86 + publicSessions = true 87 + ) 88 + 89 + // For profile, we use rkey "self" since there's only one per account 90 + val requestBody = CreateRecordRequestWithRkey( 91 + repo = session.did, 92 + collection = "com.jollywhoppers.minecraft.player.profile", 93 + rkey = "self", 94 + record = record 95 + ) 96 + 97 + sessionManager.makeAuthenticatedRequest( 98 + uuid = playerUuid, 99 + method = "POST", 100 + endpoint = "com.atproto.repo.putRecord", 101 + body = json.encodeToString(requestBody) 102 + ).getOrThrow() 103 + } 104 + 105 + /** 106 + * Example: Create an achievement record 107 + */ 108 + suspend fun createAchievementRecord( 109 + playerUuid: UUID, 110 + achievementId: String, 111 + achievementName: String, 112 + achievementDescription: String, 113 + category: String, 114 + isChallenge: Boolean = false 115 + ): Result<String> = runCatching { 116 + val session = sessionManager.getSession(playerUuid).getOrThrow() 117 + 118 + val record = AchievementRecord( 119 + `$type` = "com.jollywhoppers.minecraft.achievement", 120 + player = PlayerReference( 121 + uuid = playerUuid.toString(), 122 + username = "ExamplePlayer" 123 + ), 124 + server = ServerReference( 125 + serverId = "example-server-id", 126 + serverName = "Example Server" 127 + ), 128 + achievementId = achievementId, 129 + achievementName = achievementName, 130 + achievementDescription = achievementDescription, 131 + achievedAt = java.time.Instant.now().toString(), 132 + category = category, 133 + isChallenge = isChallenge 134 + ) 135 + 136 + val requestBody = CreateRecordRequest( 137 + repo = session.did, 138 + collection = "com.jollywhoppers.minecraft.achievement", 139 + record = record 140 + ) 141 + 142 + sessionManager.makeAuthenticatedRequest( 143 + uuid = playerUuid, 144 + method = "POST", 145 + endpoint = "com.atproto.repo.createRecord", 146 + body = json.encodeToString(requestBody) 147 + ).getOrThrow() 148 + } 149 + 150 + // Data classes matching our lexicon schemas 151 + 152 + @Serializable 153 + data class PlayerReference( 154 + val uuid: String, 155 + val username: String 156 + ) 157 + 158 + @Serializable 159 + data class ServerReference( 160 + val serverId: String, 161 + val serverName: String 162 + ) 163 + 164 + @Serializable 165 + data class Statistic( 166 + val key: String, 167 + val value: Int, 168 + val category: String? = null 169 + ) 170 + 171 + @Serializable 172 + data class PlayerStatsRecord( 173 + val `$type`: String, 174 + val player: PlayerReference, 175 + val server: ServerReference, 176 + val statistics: List<Statistic>, 177 + val playtimeMinutes: Int, 178 + val level: Int, 179 + val gamemode: String, 180 + val dimension: String? = null, 181 + val syncedAt: String 182 + ) 183 + 184 + @Serializable 185 + data class PlayerProfileRecord( 186 + val `$type`: String, 187 + val player: PlayerReference, 188 + val displayName: String?, 189 + val bio: String?, 190 + val createdAt: String, 191 + val updatedAt: String? = null, 192 + val publicStats: Boolean, 193 + val publicSessions: Boolean 194 + ) 195 + 196 + @Serializable 197 + data class AchievementRecord( 198 + val `$type`: String, 199 + val player: PlayerReference, 200 + val server: ServerReference, 201 + val achievementId: String, 202 + val achievementName: String, 203 + val achievementDescription: String, 204 + val achievedAt: String, 205 + val category: String, 206 + val isChallenge: Boolean 207 + ) 208 + 209 + @Serializable 210 + data class CreateRecordRequest( 211 + val repo: String, 212 + val collection: String, 213 + val record: Any 214 + ) 215 + 216 + @Serializable 217 + data class CreateRecordRequestWithRkey( 218 + val repo: String, 219 + val collection: String, 220 + val rkey: String, 221 + val record: Any 222 + ) 223 + } 224 + 225 + /** 226 + * Usage example: 227 + * 228 + * ```kotlin 229 + * val example = RecordCreationExample(sessionManager) 230 + * 231 + * // Create a stats snapshot 232 + * val stats = listOf( 233 + * Statistic("minecraft:killed.minecraft.zombie", 42, "killed"), 234 + * Statistic("minecraft:mined.minecraft.diamond_ore", 15, "mined") 235 + * ) 236 + * 237 + * example.createPlayerStatsRecord( 238 + * playerUuid = player.uuid, 239 + * statistics = stats, 240 + * playtimeMinutes = 180, 241 + * level = 25 242 + * ).onSuccess { response -> 243 + * logger.info("Stats synced successfully!") 244 + * }.onFailure { error -> 245 + * logger.error("Failed to sync stats", error) 246 + * } 247 + * ``` 248 + */
+102
src/main/resources/lexicons/README.md
··· 1 + # atproto-connect Lexicons 2 + 3 + This directory contains Lexicon schema definitions for the `com.jollywhoppers.minecraft.*` namespace, enabling Minecraft data to be stored and shared on the AT Protocol network. 4 + 5 + ## Schema Overview 6 + 7 + ### Core Definitions 8 + 9 + **`com.jollywhoppers.minecraft.defs`** 10 + - Common type definitions used across all Minecraft lexicons 11 + - Includes `playerReference`, `serverReference`, and `statistic` types 12 + 13 + ### Player Data 14 + 15 + **`com.jollywhoppers.minecraft.player.profile`** (key: `literal:self`) 16 + - Links a Minecraft player UUID to their AT Protocol identity 17 + - Single record per account serving as the primary identity record 18 + - Includes privacy controls for stats and session visibility 19 + 20 + **`com.jollywhoppers.minecraft.player.stats`** (key: `tid`) 21 + - Snapshots of player statistics (blocks mined, mobs killed, etc.) 22 + - Suitable for cross-server leaderboards 23 + - Can be synced periodically or on significant milestones 24 + 25 + **`com.jollywhoppers.minecraft.player.session`** (key: `tid`) 26 + - Individual play session records (join/leave times) 27 + - Tracks playtime and connection history 28 + - Useful for activity tracking and analytics 29 + 30 + ### Achievements 31 + 32 + **`com.jollywhoppers.minecraft.achievement`** (key: `tid`) 33 + - Records when players earn achievements/advancements 34 + - Supports both vanilla and custom achievements 35 + - Can be scoped to specific servers or global 36 + 37 + ### Leaderboards 38 + 39 + **`com.jollywhoppers.minecraft.leaderboard`** (key: `tid`) 40 + - Pre-computed leaderboard entries for specific statistics 41 + - Supports server-specific and global leaderboards 42 + - Can track different time periods (all-time, monthly, weekly, daily) 43 + 44 + ### Server Status 45 + 46 + **`com.jollywhoppers.minecraft.server.status`** (key: `literal:self`) 47 + - Server information and current status 48 + - Player counts, version info, server settings 49 + - Useful for server discovery and monitoring 50 + 51 + ## Record Keys 52 + 53 + - **`tid`**: Time-ordered identifier - used for records that occur multiple times 54 + - **`literal:self`**: Single instance record - only one per account 55 + 56 + ## Usage Example 57 + 58 + When a player completes a milestone, the mod would: 59 + 60 + 1. Read current stats from the Minecraft player data 61 + 2. Create a `com.jollywhoppers.minecraft.player.stats` record 62 + 3. Optionally update the `com.jollywhoppers.minecraft.leaderboard` if they rank 63 + 4. Publish to the player's AT Protocol repository 64 + 65 + ## Data Flow 66 + 67 + ``` 68 + Minecraft Server (Fabric) 69 + 70 + Player Events (blocks mined, mobs killed, etc.) 71 + 72 + atproto-connect Mod 73 + 74 + Create/Update Lexicon Records 75 + 76 + AT Protocol PDS 77 + 78 + Federated Network (visible to all indexers) 79 + 80 + Custom AppViews (leaderboards, stats displays) 81 + ``` 82 + 83 + ## Privacy Considerations 84 + 85 + - Players control what data is synced via `publicStats` and `publicSessions` flags 86 + - Server operators can configure which statistics are tracked 87 + - All data is published to the player's own AT Protocol repository 88 + - Players can delete their data at any time 89 + 90 + ## Future Enhancements 91 + 92 + - Event records (player kills, deaths, notable achievements) 93 + - Trading/economy tracking 94 + - Guild/team statistics 95 + - Custom server-specific lexicons 96 + - Rich media attachments (screenshots of achievements) 97 + 98 + ## References 99 + 100 + - [AT Protocol Lexicon Specification](https://atproto.com/specs/lexicon) 101 + - [AT Protocol Data Model](https://atproto.com/specs/data-model) 102 + - [Lexicon Style Guide](https://atproto.com/guides/lexicon-style-guide)
+57
src/main/resources/lexicons/com.jollywhoppers.minecraft.achievement.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.jollywhoppers.minecraft.achievement", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Achievement or advancement earned by a player in Minecraft.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["player", "achievementId", "achievedAt"], 12 + "properties": { 13 + "player": { 14 + "type": "ref", 15 + "ref": "com.jollywhoppers.minecraft.defs#playerReference", 16 + "description": "Player who earned the achievement" 17 + }, 18 + "server": { 19 + "type": "ref", 20 + "ref": "com.jollywhoppers.minecraft.defs#serverReference", 21 + "description": "Server where the achievement was earned" 22 + }, 23 + "achievementId": { 24 + "type": "string", 25 + "description": "Achievement identifier (e.g., 'minecraft:story/mine_diamond')", 26 + "maxLength": 256 27 + }, 28 + "achievementName": { 29 + "type": "string", 30 + "description": "Human-readable achievement name", 31 + "maxLength": 256 32 + }, 33 + "achievementDescription": { 34 + "type": "string", 35 + "description": "Achievement description", 36 + "maxLength": 1000 37 + }, 38 + "achievedAt": { 39 + "type": "string", 40 + "format": "datetime", 41 + "description": "When the achievement was earned" 42 + }, 43 + "category": { 44 + "type": "string", 45 + "description": "Achievement category (e.g., 'story', 'nether', 'end', 'adventure', 'husbandry')", 46 + "maxLength": 64 47 + }, 48 + "isChallenge": { 49 + "type": "boolean", 50 + "description": "Whether this is a challenge advancement", 51 + "default": false 52 + } 53 + } 54 + } 55 + } 56 + } 57 + }
+66
src/main/resources/lexicons/com.jollywhoppers.minecraft.defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.jollywhoppers.minecraft.defs", 4 + "defs": { 5 + "playerReference": { 6 + "type": "object", 7 + "description": "Reference to a Minecraft player", 8 + "required": ["uuid", "username"], 9 + "properties": { 10 + "uuid": { 11 + "type": "string", 12 + "description": "Player's Minecraft UUID", 13 + "maxLength": 36 14 + }, 15 + "username": { 16 + "type": "string", 17 + "description": "Player's Minecraft username", 18 + "maxLength": 16 19 + } 20 + } 21 + }, 22 + "serverReference": { 23 + "type": "object", 24 + "description": "Reference to a Minecraft server", 25 + "required": ["serverId", "serverName"], 26 + "properties": { 27 + "serverId": { 28 + "type": "string", 29 + "description": "Unique server identifier", 30 + "maxLength": 128 31 + }, 32 + "serverName": { 33 + "type": "string", 34 + "description": "Human-readable server name", 35 + "maxLength": 256 36 + }, 37 + "serverAddress": { 38 + "type": "string", 39 + "description": "Server address (optional)", 40 + "maxLength": 512 41 + } 42 + } 43 + }, 44 + "statistic": { 45 + "type": "object", 46 + "description": "A single statistic entry", 47 + "required": ["key", "value"], 48 + "properties": { 49 + "key": { 50 + "type": "string", 51 + "description": "Statistic identifier (e.g., 'minecraft:mined', 'minecraft:killed')", 52 + "maxLength": 256 53 + }, 54 + "value": { 55 + "type": "integer", 56 + "description": "Statistic value" 57 + }, 58 + "category": { 59 + "type": "string", 60 + "description": "Category of statistic (e.g., 'mined', 'killed', 'used', 'broken')", 61 + "maxLength": 64 62 + } 63 + } 64 + } 65 + } 66 + }
+52
src/main/resources/lexicons/com.jollywhoppers.minecraft.leaderboard.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.jollywhoppers.minecraft.leaderboard", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Leaderboard entry for a specific statistic. Useful for displaying top players across servers.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["player", "statisticKey", "value", "updatedAt"], 12 + "properties": { 13 + "player": { 14 + "type": "ref", 15 + "ref": "com.jollywhoppers.minecraft.defs#playerReference", 16 + "description": "Player on the leaderboard" 17 + }, 18 + "server": { 19 + "type": "ref", 20 + "ref": "com.jollywhoppers.minecraft.defs#serverReference", 21 + "description": "Server scope (omit for global leaderboard)" 22 + }, 23 + "statisticKey": { 24 + "type": "string", 25 + "description": "The statistic being ranked (e.g., 'total_kills', 'diamonds_mined', 'playtime')", 26 + "maxLength": 256 27 + }, 28 + "value": { 29 + "type": "integer", 30 + "description": "The statistic value" 31 + }, 32 + "rank": { 33 + "type": "integer", 34 + "description": "Player's rank for this statistic", 35 + "minimum": 1 36 + }, 37 + "updatedAt": { 38 + "type": "string", 39 + "format": "datetime", 40 + "description": "When this leaderboard entry was last updated" 41 + }, 42 + "period": { 43 + "type": "string", 44 + "description": "Time period for leaderboard (e.g., 'all-time', 'monthly', 'weekly', 'daily')", 45 + "enum": ["all-time", "yearly", "monthly", "weekly", "daily"], 46 + "default": "all-time" 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+64
src/main/resources/lexicons/com.jollywhoppers.minecraft.player.profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.jollywhoppers.minecraft.player.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Links a Minecraft player identity to an AT Protocol account. This is the primary identity record.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["player", "createdAt"], 12 + "properties": { 13 + "player": { 14 + "type": "ref", 15 + "ref": "com.jollywhoppers.minecraft.defs#playerReference", 16 + "description": "Minecraft player identity" 17 + }, 18 + "displayName": { 19 + "type": "string", 20 + "description": "Display name for the player", 21 + "maxLength": 128, 22 + "maxGraphemes": 64 23 + }, 24 + "bio": { 25 + "type": "string", 26 + "description": "Player bio or description", 27 + "maxLength": 2560, 28 + "maxGraphemes": 256 29 + }, 30 + "primaryServer": { 31 + "type": "ref", 32 + "ref": "com.jollywhoppers.minecraft.defs#serverReference", 33 + "description": "Player's primary/home server" 34 + }, 35 + "favoriteGameMode": { 36 + "type": "string", 37 + "description": "Preferred game mode", 38 + "enum": ["survival", "creative", "adventure", "spectator"] 39 + }, 40 + "createdAt": { 41 + "type": "string", 42 + "format": "datetime", 43 + "description": "When this profile was created" 44 + }, 45 + "updatedAt": { 46 + "type": "string", 47 + "format": "datetime", 48 + "description": "When this profile was last updated" 49 + }, 50 + "publicStats": { 51 + "type": "boolean", 52 + "description": "Whether stats should be publicly visible", 53 + "default": true 54 + }, 55 + "publicSessions": { 56 + "type": "boolean", 57 + "description": "Whether play sessions should be publicly visible", 58 + "default": true 59 + } 60 + } 61 + } 62 + } 63 + } 64 + }
+47
src/main/resources/lexicons/com.jollywhoppers.minecraft.player.session.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.jollywhoppers.minecraft.player.session", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A single Minecraft play session. Tracks when a player joined and left a server.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["player", "joinedAt"], 12 + "properties": { 13 + "player": { 14 + "type": "ref", 15 + "ref": "com.jollywhoppers.minecraft.defs#playerReference", 16 + "description": "Player who had this session" 17 + }, 18 + "server": { 19 + "type": "ref", 20 + "ref": "com.jollywhoppers.minecraft.defs#serverReference", 21 + "description": "Server where the session occurred" 22 + }, 23 + "joinedAt": { 24 + "type": "string", 25 + "format": "datetime", 26 + "description": "When the player joined the server" 27 + }, 28 + "leftAt": { 29 + "type": "string", 30 + "format": "datetime", 31 + "description": "When the player left the server (null if still online)" 32 + }, 33 + "durationMinutes": { 34 + "type": "integer", 35 + "description": "Session duration in minutes", 36 + "minimum": 0 37 + }, 38 + "quitReason": { 39 + "type": "string", 40 + "description": "Reason for disconnection", 41 + "maxLength": 256 42 + } 43 + } 44 + } 45 + } 46 + } 47 + }
+62
src/main/resources/lexicons/com.jollywhoppers.minecraft.player.stats.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.jollywhoppers.minecraft.player.stats", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Player statistics snapshot from Minecraft. Suitable for leaderboards and cross-server tracking.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["player", "statistics", "syncedAt"], 12 + "properties": { 13 + "player": { 14 + "type": "ref", 15 + "ref": "com.jollywhoppers.minecraft.defs#playerReference", 16 + "description": "Player this stat snapshot belongs to" 17 + }, 18 + "server": { 19 + "type": "ref", 20 + "ref": "com.jollywhoppers.minecraft.defs#serverReference", 21 + "description": "Server where these stats were recorded" 22 + }, 23 + "statistics": { 24 + "type": "array", 25 + "description": "Array of player statistics", 26 + "items": { 27 + "type": "ref", 28 + "ref": "com.jollywhoppers.minecraft.defs#statistic" 29 + }, 30 + "maxLength": 1000 31 + }, 32 + "playtimeMinutes": { 33 + "type": "integer", 34 + "description": "Total playtime in minutes", 35 + "minimum": 0 36 + }, 37 + "level": { 38 + "type": "integer", 39 + "description": "Player experience level", 40 + "minimum": 0 41 + }, 42 + "gamemode": { 43 + "type": "string", 44 + "description": "Current gamemode", 45 + "enum": ["survival", "creative", "adventure", "spectator"], 46 + "default": "survival" 47 + }, 48 + "dimension": { 49 + "type": "string", 50 + "description": "Current dimension", 51 + "maxLength": 256 52 + }, 53 + "syncedAt": { 54 + "type": "string", 55 + "format": "datetime", 56 + "description": "When these stats were synced to AT Protocol" 57 + } 58 + } 59 + } 60 + } 61 + } 62 + }
+82
src/main/resources/lexicons/com.jollywhoppers.minecraft.server.status.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.jollywhoppers.minecraft.server.status", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Minecraft server status snapshot. Can be used for server discovery and monitoring.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["server", "version", "updatedAt"], 12 + "properties": { 13 + "server": { 14 + "type": "ref", 15 + "ref": "com.jollywhoppers.minecraft.defs#serverReference", 16 + "description": "Server information" 17 + }, 18 + "version": { 19 + "type": "string", 20 + "description": "Minecraft server version", 21 + "maxLength": 64 22 + }, 23 + "protocolVersion": { 24 + "type": "integer", 25 + "description": "Minecraft protocol version number" 26 + }, 27 + "maxPlayers": { 28 + "type": "integer", 29 + "description": "Maximum player capacity", 30 + "minimum": 0 31 + }, 32 + "onlinePlayers": { 33 + "type": "integer", 34 + "description": "Current number of online players", 35 + "minimum": 0 36 + }, 37 + "playerSample": { 38 + "type": "array", 39 + "description": "Sample of online players", 40 + "items": { 41 + "type": "ref", 42 + "ref": "com.jollywhoppers.minecraft.defs#playerReference" 43 + }, 44 + "maxLength": 100 45 + }, 46 + "motd": { 47 + "type": "string", 48 + "description": "Server message of the day", 49 + "maxLength": 500, 50 + "maxGraphemes": 100 51 + }, 52 + "gameMode": { 53 + "type": "string", 54 + "description": "Primary game mode", 55 + "maxLength": 64 56 + }, 57 + "difficulty": { 58 + "type": "string", 59 + "description": "Server difficulty", 60 + "enum": ["peaceful", "easy", "normal", "hard"], 61 + "default": "normal" 62 + }, 63 + "hardcore": { 64 + "type": "boolean", 65 + "description": "Whether hardcore mode is enabled", 66 + "default": false 67 + }, 68 + "pvpEnabled": { 69 + "type": "boolean", 70 + "description": "Whether PvP is enabled", 71 + "default": true 72 + }, 73 + "updatedAt": { 74 + "type": "string", 75 + "format": "datetime", 76 + "description": "When this status was recorded" 77 + } 78 + } 79 + } 80 + } 81 + } 82 + }