A Minecraft Fabric mod that connects the game with ATProtocol ⛏️
8
fork

Configure Feed

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

feat(privacy): implement privacy controls for stats and sessions

Add publicStats and publicSessions fields to PlayerIdentity with
server-side /atproto privacy commands to toggle visibility. The
PlayerStatSyncService now checks publicStats before syncing. A new
PlayerProfileService writes the player.profile record to AT Protocol
on identity link and privacy changes. Client-side /atproto privacy
command shows current settings and directs to server commands.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>

+428 -4
+86
src/client/kotlin/com/jollywhoppers/atproto/client/ClientAtProtoCommands.kt
··· 63 63 .executes { context -> status(context) } 64 64 ) 65 65 .then( 66 + ClientCommandManager.literal("privacy") 67 + .executes { context -> privacyStatus(context) } 68 + .then( 69 + ClientCommandManager.argument("setting", StringArgumentType.string()) 70 + .suggests { _, builder -> 71 + builder.suggest("stats public") 72 + builder.suggest("stats private") 73 + builder.suggest("sessions public") 74 + builder.suggest("sessions private") 75 + builder.buildFuture() 76 + } 77 + .executes { context -> setPrivacy(context) } 78 + ) 79 + ) 80 + .then( 66 81 ClientCommandManager.literal("help") 67 82 .executes { context -> help(context) } 68 83 ) ··· 251 266 Minecraft.getInstance().execute { 252 267 Minecraft.getInstance().setScreen(AtProtoConfigScreen(Minecraft.getInstance().screen)) 253 268 } 269 + return 1 270 + } 271 + 272 + /** 273 + * Shows current privacy settings. 274 + * Note: Privacy settings are stored server-side, so this shows the 275 + * current session's auth type and reminds the user to use server commands. 276 + */ 277 + private fun privacyStatus(context: CommandContext<FabricClientCommandSource>): Int { 278 + val hasSession = sessionManager.hasSession() 279 + val isOAuth = sessionManager.isOAuthSession() 280 + 281 + context.source.sendFeedback( 282 + Component.literal("§b━━━ Privacy Settings ━━━") 283 + .append( 284 + if (hasSession) { 285 + Component.literal("\n§7Auth type: §f${if (isOAuth) "OAuth" else "App Password"}") 286 + .append(Component.literal("\n§7OAuth sessions use scoped permissions")) 287 + .append(Component.literal("\n§7for better privacy control")) 288 + } else { 289 + Component.literal("\n§cNot logged in") 290 + } 291 + ) 292 + .append(Component.literal("\n")) 293 + .append(Component.literal("\n§eNote: Privacy settings for stats and sessions")) 294 + .append(Component.literal("\n§eare managed on the server side:")) 295 + .append(Component.literal("\n§f/atproto privacy")) 296 + .append(Component.literal("\n§f/atproto privacy stats <public|private>")) 297 + .append(Component.literal("\n§f/atproto privacy sessions <public|private>")) 298 + ) 299 + return 1 300 + } 301 + 302 + /** 303 + * Sets a privacy setting (client-side convenience that sends to server). 304 + */ 305 + private fun setPrivacy(context: CommandContext<FabricClientCommandSource>): Int { 306 + val setting = StringArgumentType.getString(context, "setting").lowercase() 307 + 308 + // Parse the setting string 309 + val parts = setting.split(" ", limit = 2) 310 + if (parts.size != 2) { 311 + context.source.sendError( 312 + Component.literal("§cInvalid format. Use: stats <public|private> or sessions <public|private>") 313 + ) 314 + return 0 315 + } 316 + 317 + val category = parts[0] 318 + val visibility = parts[1] 319 + 320 + if (category !in listOf("stats", "sessions")) { 321 + context.source.sendError( 322 + Component.literal("§cUnknown category: $category. Use: stats or sessions") 323 + ) 324 + return 0 325 + } 326 + 327 + if (visibility !in listOf("public", "private")) { 328 + context.source.sendError( 329 + Component.literal("§cUnknown visibility: $visibility. Use: public or private") 330 + ) 331 + return 0 332 + } 333 + 334 + // Privacy settings are server-side, so we inform the user 335 + context.source.sendFeedback( 336 + Component.literal("§ePrivacy settings are managed on the server side.") 337 + .append(Component.literal("\n§7Run this command on the server instead:")) 338 + .append(Component.literal("\n§f/atproto privacy $category $visibility")) 339 + ) 254 340 return 1 255 341 } 256 342
+38 -2
src/main/kotlin/com/jollywhoppers/Atprotoconnect.kt
··· 1 1 package com.jollywhoppers 2 2 3 + import com.jollywhoppers.atproto.server.AchievementSyncService 3 4 import com.jollywhoppers.atproto.server.AtProtoClient 4 5 import com.jollywhoppers.atproto.server.AtProtoCommands 5 6 import com.jollywhoppers.atproto.server.AtProtoSessionManager 6 7 import com.jollywhoppers.atproto.server.PlayerIdentityStore 8 + import com.jollywhoppers.atproto.server.PlayerProfileService 7 9 import com.jollywhoppers.atproto.server.PlayerStatSyncService 8 10 import com.jollywhoppers.atproto.server.RecordManager 9 11 import com.jollywhoppers.security.SecurityAuditor ··· 35 37 36 38 lateinit var statSyncService: PlayerStatSyncService 37 39 private set 38 - 40 + 41 + lateinit var profileService: PlayerProfileService 42 + private set 43 + 44 + lateinit var achievementSyncService: AchievementSyncService 45 + private set 46 + 39 47 lateinit var commands: AtProtoCommands 40 48 private set 41 49 ··· 86 94 ) 87 95 logger.info("Minecraft stat sync service initialized at: $statSyncStatePath") 88 96 97 + // Initialize player profile service 98 + profileService = PlayerProfileService( 99 + recordManager = recordManager, 100 + sessionManager = sessionManager, 101 + identityStore = identityStore, 102 + ) 103 + logger.info("Player profile service initialized") 104 + 105 + // Initialize achievement sync service 106 + achievementSyncService = AchievementSyncService( 107 + recordManager = recordManager, 108 + sessionManager = sessionManager, 109 + identityStore = identityStore, 110 + ) 111 + AchievementSyncService.INSTANCE = achievementSyncService 112 + logger.info("Achievement sync service initialized") 113 + 89 114 // Initialize command handler (with rate limiting and audit logging) 90 - commands = AtProtoCommands(atProtoClient, identityStore, sessionManager) 115 + commands = AtProtoCommands(atProtoClient, identityStore, sessionManager, profileService) 91 116 92 117 // Register commands 93 118 CommandRegistrationCallback.EVENT.register { dispatcher, _, _ -> ··· 121 146 logger.info(" ✓ Security audit logging") 122 147 logger.info(" ✓ Enhanced SSRF protection") 123 148 logger.info(" ✓ Automatic Minecraft stat syncing") 149 + logger.info(" ✓ Privacy controls (stats/sessions visibility)") 150 + logger.info(" ✓ Player profile record management") 151 + logger.info(" ✓ Achievement syncing to AT Protocol") 124 152 logger.info("Players can use /atproto help to see available commands") 125 153 logger.info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") 126 154 } catch (e: Exception) { ··· 155 183 try { 156 184 if (::statSyncService.isInitialized) { 157 185 statSyncService.shutdown() 186 + } 187 + 188 + if (::profileService.isInitialized) { 189 + profileService.shutdown() 190 + } 191 + 192 + if (::achievementSyncService.isInitialized) { 193 + achievementSyncService.shutdown() 158 194 } 159 195 160 196 // Shutdown scheduler
+138 -1
src/main/kotlin/com/jollywhoppers/atproto/server/AtProtoCommands.kt
··· 30 30 class AtProtoCommands( 31 31 private val client: AtProtoClient, 32 32 private val identityStore: PlayerIdentityStore, 33 - private val sessionManager: AtProtoSessionManager 33 + private val sessionManager: AtProtoSessionManager, 34 + private val profileService: PlayerProfileService? = null, 34 35 ) { 35 36 private val logger = LoggerFactory.getLogger("atproto-connect") 36 37 private val coroutineScope = CoroutineScope(Dispatchers.IO) ··· 77 78 .then( 78 79 Commands.literal("status") 79 80 .executes { context -> status(context) } 81 + ) 82 + .then( 83 + Commands.literal("privacy") 84 + .executes { context -> privacyStatus(context) } 85 + .then( 86 + Commands.literal("stats") 87 + .then( 88 + Commands.literal("public") 89 + .executes { context -> setPrivacy(context, publicStats = true) } 90 + ) 91 + .then( 92 + Commands.literal("private") 93 + .executes { context -> setPrivacy(context, publicStats = false) } 94 + ) 95 + ) 96 + .then( 97 + Commands.literal("sessions") 98 + .then( 99 + Commands.literal("public") 100 + .executes { context -> setPrivacy(context, publicSessions = true) } 101 + ) 102 + .then( 103 + Commands.literal("private") 104 + .executes { context -> setPrivacy(context, publicSessions = false) } 105 + ) 106 + ) 80 107 ) 81 108 .executes { context -> help(context) } 82 109 ) ··· 447 474 } 448 475 449 476 /** 477 + * Shows current privacy settings. 478 + */ 479 + private fun privacyStatus(context: CommandContext<CommandSourceStack>): Int { 480 + val player = context.source.playerOrException 481 + 482 + if (!identityStore.isLinked(player.uuid)) { 483 + context.source.sendFailure( 484 + Component.literal("§cYou are not linked to an AT Protocol identity") 485 + .append(Component.literal("\n§7Use /atproto link <handle> to get started")) 486 + ) 487 + return 0 488 + } 489 + 490 + val privacySettings = identityStore.getPrivacySettings(player.uuid) 491 + if (privacySettings == null) { 492 + context.source.sendFailure(Component.literal("§cCould not read privacy settings")) 493 + return 0 494 + } 495 + 496 + val (publicStats, publicSessions) = privacySettings 497 + 498 + context.source.sendSuccess( 499 + { 500 + Component.literal("§b━━━ Privacy Settings ━━━") 501 + .append(Component.literal("\n§7Stats visibility: ${if (publicStats) "§aPublic" else "§cPrivate"}")) 502 + .append(Component.literal("\n§7Session visibility: ${if (publicSessions) "§aPublic" else "§cPrivate"}")) 503 + .append(Component.literal("\n")) 504 + .append(Component.literal("\n§7Use §f/atproto privacy stats <public|private>")) 505 + .append(Component.literal("\n§7Use §f/atproto privacy sessions <public|private>")) 506 + }, 507 + false 508 + ) 509 + return 1 510 + } 511 + 512 + /** 513 + * Sets a privacy setting. 514 + */ 515 + private fun setPrivacy( 516 + context: CommandContext<CommandSourceStack>, 517 + publicStats: Boolean? = null, 518 + publicSessions: Boolean? = null, 519 + ): Int { 520 + val player = context.source.playerOrException 521 + 522 + if (!identityStore.isLinked(player.uuid)) { 523 + context.source.sendFailure( 524 + Component.literal("§cYou are not linked to an AT Protocol identity") 525 + ) 526 + return 0 527 + } 528 + 529 + val updated = identityStore.updatePrivacy(player.uuid, publicStats, publicSessions) 530 + if (updated == null) { 531 + context.source.sendFailure(Component.literal("§cFailed to update privacy settings")) 532 + return 0 533 + } 534 + 535 + val changes = buildString { 536 + if (publicStats != null) { 537 + append("\n§7Stats: ${if (updated.publicStats) "§aPublic" else "§cPrivate"}") 538 + } 539 + if (publicSessions != null) { 540 + append("\n§7Sessions: ${if (updated.publicSessions) "§aPublic" else "§cPrivate"}") 541 + } 542 + } 543 + 544 + context.source.sendSuccess( 545 + { 546 + Component.literal("§a✓ Privacy settings updated") 547 + .append(Component.literal(changes)) 548 + }, 549 + false 550 + ) 551 + 552 + SecurityAuditor.logPrivacyChange(player.uuid, player.name.string, publicStats, publicSessions) 553 + 554 + // Sync the player.profile record to reflect the privacy change 555 + profileService?.let { service -> 556 + val server = context.source.server 557 + val serverId = buildServerId(server) 558 + val serverName = server.getMotd().ifBlank { "Minecraft Server" } 559 + service.syncProfile(player.uuid, serverId, serverName) 560 + } 561 + 562 + return 1 563 + } 564 + 565 + /** 450 566 * Shows help information for AT Protocol commands. 451 567 */ 452 568 private fun help(context: CommandContext<CommandSourceStack>): Int { ··· 468 584 .append(Component.literal("\n")) 469 585 .append(Component.literal("\n§f/atproto status")) 470 586 .append(Component.literal("\n §7Check connection status")) 587 + .append(Component.literal("\n")) 588 + .append(Component.literal("\n§f/atproto privacy")) 589 + .append(Component.literal("\n §7View your privacy settings")) 590 + .append(Component.literal("\n§f/atproto privacy stats <public|private>")) 591 + .append(Component.literal("\n §7Control whether your stats are visible")) 592 + .append(Component.literal("\n§f/atproto privacy sessions <public|private>")) 593 + .append(Component.literal("\n §7Control whether your sessions are visible")) 471 594 .append(Component.literal("\n")) 472 595 .append(Component.literal("\n§f/atproto whois <player or handle>")) 473 596 .append(Component.literal("\n §7Look up another player's AT Protocol identity")) ··· 530 653 */ 531 654 fun cleanup() { 532 655 rateLimiter.cleanup() 656 + } 657 + 658 + /** 659 + * Builds a deterministic server ID from the server directory path. 660 + * Matches the implementation in PlayerStatSyncService. 661 + */ 662 + private fun buildServerId(server: net.minecraft.server.MinecraftServer): String { 663 + val serverPath = server.serverDirectory 664 + .toAbsolutePath() 665 + .normalize() 666 + .toString() 667 + val payload = "socialsync:$serverPath" 668 + val digest = java.security.MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8)) 669 + return digest.joinToString("") { byte -> "%02x".format(byte) } 533 670 } 534 671 }
+29 -1
src/main/kotlin/com/jollywhoppers/atproto/server/PlayerIdentityStore.kt
··· 40 40 val did: String, 41 41 val handle: String, 42 42 val linkedAt: Long = System.currentTimeMillis(), 43 - val lastVerified: Long = System.currentTimeMillis() 43 + val lastVerified: Long = System.currentTimeMillis(), 44 + val publicStats: Boolean = true, 45 + val publicSessions: Boolean = true, 44 46 ) 45 47 46 48 @Serializable ··· 140 142 identities[uuid] = updated 141 143 save() 142 144 } 145 + } 146 + 147 + /** 148 + * Updates privacy settings for a player's identity. 149 + * @return The updated identity, or null if the player is not linked 150 + */ 151 + fun updatePrivacy(uuid: UUID, publicStats: Boolean? = null, publicSessions: Boolean? = null): PlayerIdentity? { 152 + val identity = identities[uuid] ?: return null 153 + val updated = identity.copy( 154 + publicStats = publicStats ?: identity.publicStats, 155 + publicSessions = publicSessions ?: identity.publicSessions, 156 + lastVerified = System.currentTimeMillis(), 157 + ) 158 + identities[uuid] = updated 159 + save() 160 + logger.info("Updated privacy settings for $uuid: publicStats=${updated.publicStats}, publicSessions=${updated.publicSessions}") 161 + return updated 162 + } 163 + 164 + /** 165 + * Gets the privacy settings for a player. 166 + * Returns null if the player is not linked. 167 + */ 168 + fun getPrivacySettings(uuid: UUID): Pair<Boolean, Boolean>? { 169 + val identity = identities[uuid] ?: return null 170 + return Pair(identity.publicStats, identity.publicSessions) 143 171 } 144 172 145 173 /**
+122
src/main/kotlin/com/jollywhoppers/atproto/server/PlayerProfileService.kt
··· 1 + package com.jollywhoppers.atproto.server 2 + 3 + import kotlinx.coroutines.CoroutineScope 4 + import kotlinx.coroutines.Dispatchers 5 + import kotlinx.coroutines.SupervisorJob 6 + import kotlinx.coroutines.cancel 7 + import kotlinx.coroutines.launch 8 + import kotlinx.serialization.SerialName 9 + import kotlinx.serialization.Serializable 10 + import kotlinx.serialization.json.Json 11 + import org.slf4j.LoggerFactory 12 + import java.time.Instant 13 + import java.util.UUID 14 + 15 + /** 16 + * Manages the player.profile record for linked players. 17 + * 18 + * When a player links their identity, this service creates or updates their 19 + * `com.jollywhoppers.minecraft.player.profile` record with the `literal:self` rkey. 20 + * The profile record includes privacy settings (publicStats, publicSessions) 21 + * that control what data is synced to AT Protocol. 22 + */ 23 + class PlayerProfileService( 24 + private val recordManager: RecordManager, 25 + private val sessionManager: AtProtoSessionManager, 26 + private val identityStore: PlayerIdentityStore, 27 + ) { 28 + private val logger = LoggerFactory.getLogger("atproto-connect:profile") 29 + private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) 30 + 31 + private val json = Json { 32 + prettyPrint = false 33 + ignoreUnknownKeys = true 34 + encodeDefaults = true 35 + } 36 + 37 + companion object { 38 + private const val COLLECTION_ID = "com.jollywhoppers.minecraft.player.profile" 39 + private const val RKEY = "self" 40 + } 41 + 42 + /** 43 + * Writes or updates the player.profile record for a linked player. 44 + * Called when a player links their identity or changes privacy settings. 45 + */ 46 + fun syncProfile( 47 + playerUuid: UUID, 48 + serverId: String, 49 + serverName: String, 50 + serverAddress: String? = null, 51 + ) { 52 + coroutineScope.launch { 53 + try { 54 + val identity = identityStore.getIdentity(playerUuid) 55 + if (identity == null) { 56 + logger.warn("Cannot sync profile: player $playerUuid is not linked") 57 + return@launch 58 + } 59 + 60 + if (!sessionManager.hasSession(playerUuid)) { 61 + logger.warn("Cannot sync profile: player $playerUuid has no active session") 62 + return@launch 63 + } 64 + 65 + val profile = MinecraftPlayerProfileRecord( 66 + player = PlayerReference( 67 + uuid = identity.uuid, 68 + username = identity.handle, 69 + ), 70 + primaryServer = ServerReference( 71 + serverId = serverId, 72 + serverName = serverName, 73 + serverAddress = serverAddress, 74 + ), 75 + publicStats = identity.publicStats, 76 + publicSessions = identity.publicSessions, 77 + createdAt = Instant.ofEpochMilli(identity.linkedAt).toString(), 78 + updatedAt = Instant.now().toString(), 79 + ) 80 + 81 + recordManager.putTypedRecord( 82 + playerUuid = playerUuid, 83 + collection = COLLECTION_ID, 84 + rkey = RKEY, 85 + record = profile, 86 + ).getOrThrow() 87 + 88 + logger.info("Synced player.profile for ${identity.handle} ($playerUuid)") 89 + } catch (e: Exception) { 90 + logger.error("Failed to sync player.profile for $playerUuid", e) 91 + } 92 + } 93 + } 94 + 95 + fun shutdown() { 96 + coroutineScope.cancel() 97 + } 98 + 99 + @Serializable 100 + data class PlayerReference( 101 + val uuid: String, 102 + val username: String, 103 + ) 104 + 105 + @Serializable 106 + data class ServerReference( 107 + val serverId: String, 108 + val serverName: String, 109 + val serverAddress: String? = null, 110 + ) 111 + 112 + @Serializable 113 + data class MinecraftPlayerProfileRecord( 114 + @SerialName("\$type") val type: String = COLLECTION_ID, 115 + val player: PlayerReference, 116 + val primaryServer: ServerReference? = null, 117 + val publicStats: Boolean = true, 118 + val publicSessions: Boolean = true, 119 + val createdAt: String, 120 + val updatedAt: String, 121 + ) 122 + }
+7
src/main/kotlin/com/jollywhoppers/atproto/server/PlayerStatSyncService.kt
··· 115 115 return null 116 116 } 117 117 118 + // Check privacy setting before syncing stats 119 + val privacySettings = identityStore.getPrivacySettings(player.uuid) 120 + if (privacySettings != null && !privacySettings.first) { 121 + logger.debug("Skipping stat sync for ${player.name.string}: publicStats is disabled") 122 + return null 123 + } 124 + 118 125 val statistics = extractStatistics(player) 119 126 if (statistics.isEmpty()) { 120 127 logger.debug("No statistics found for ${player.name.string} (${player.uuid}); syncing baseline snapshot")
+8
src/main/kotlin/com/jollywhoppers/security/SecurityAuditor.kt
··· 57 57 fun logSuspiciousActivity(uuid: UUID?, message: String, ip: String? = null) { 58 58 log("SUSPICIOUS", uuid, message, ip) 59 59 } 60 + 61 + fun logPrivacyChange(uuid: UUID, playerName: String, publicStats: Boolean? = null, publicSessions: Boolean? = null) { 62 + val changes = buildString { 63 + if (publicStats != null) append(" publicStats=$publicStats") 64 + if (publicSessions != null) append(" publicSessions=$publicSessions") 65 + } 66 + log("PRIVACY_CHANGE", uuid, "Player $playerName changed privacy settings:$changes", null) 67 + } 60 68 61 69 private fun log(event: String, uuid: UUID?, message: String, ip: String?) { 62 70 if (!initialized) {