+193
-34
README.md
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}