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.

refactor: unify sync consent into PlayerSyncPreferencesStore

PlayerSyncPreferencesStore is now the single source of truth for all
sync consent (stats, sessions, achievements, server status). The old
syncStats/syncSessions fields on PlayerIdentityStore are legacy-only
and migrated on startup.

- Wire all 4 sync services to check syncPreferencesStore instead of
identityStore.getSyncConsent()
- Expand /atproto sync commands to 4 categories
- Client-side /atproto sync now updates PreferencesManager and sends
SyncPreferencesPacket to server
- Add consent toggles to AtProtoConfigScreen
- Remove old logSyncConsentChange from SecurityAuditor
- Add migration: extractLegacySyncConsent + migrateFromIdentityStore

+297 -85
+50 -15
src/client/kotlin/com/jollywhoppers/atproto/client/ClientAtProtoCommands.kt
··· 2 2 3 3 import com.jollywhoppers.Atprotoconnect 4 4 import com.jollywhoppers.atproto.oauth.OAuthManager 5 + import com.jollywhoppers.config.PreferencesManager 5 6 import com.jollywhoppers.network.AtProtoPackets 6 7 import com.jollywhoppers.screen.AtProtoConfigScreen 7 8 import com.mojang.brigadier.CommandDispatcher ··· 72 73 builder.suggest("stats off") 73 74 builder.suggest("sessions on") 74 75 builder.suggest("sessions off") 76 + builder.suggest("achievements on") 77 + builder.suggest("achievements off") 78 + builder.suggest("server-status on") 79 + builder.suggest("server-status off") 75 80 builder.buildFuture() 76 81 } 77 82 .executes { context -> setSyncConsent(context) } ··· 271 276 272 277 /** 273 278 * Shows current sync consent settings. 274 - * Note: Sync consent settings are stored server-side, so this shows the 275 - * current session's auth type and reminds the user to use server commands. 279 + * Reads from local client preferences and sends the current state 280 + * to the server via SyncPreferencesPacket. 276 281 */ 277 282 private fun syncConsentStatus(context: CommandContext<FabricClientCommandSource>): Int { 278 283 val hasSession = sessionManager.hasSession() 279 284 val isOAuth = sessionManager.isOAuthSession() 285 + val prefs = PreferencesManager.get() 280 286 281 287 context.source.sendFeedback( 282 288 Component.literal("§b━━━ Sync Consent ━━━") ··· 285 291 .append( 286 292 if (hasSession) { 287 293 Component.literal("\n§7Auth type: §f${if (isOAuth) "OAuth" else "App Password"}") 288 - .append(Component.literal("\n§7OAuth sessions use scoped permissions")) 289 294 } else { 290 295 Component.literal("\n§cNot logged in") 291 296 } 292 297 ) 293 298 .append(Component.literal("\n")) 294 - .append(Component.literal("\n§eSync consent is managed on the server side:")) 295 - .append(Component.literal("\n§f/atproto sync")) 296 - .append(Component.literal("\n§f/atproto sync stats <on|off>")) 297 - .append(Component.literal("\n§f/atproto sync sessions <on|off>")) 299 + .append(Component.literal("\n§7Stats syncing: ${if (prefs.syncStatsEnabled) "§aOn" else "§cOff"}")) 300 + .append(Component.literal("\n§7Session syncing: ${if (prefs.syncSessionsEnabled) "§aOn" else "§cOff"}")) 301 + .append(Component.literal("\n§7Achievement syncing: ${if (prefs.syncAchievementsEnabled) "§aOn" else "§cOff"}")) 302 + .append(Component.literal("\n§7Server status syncing: ${if (prefs.syncServerStatusEnabled) "§aOn" else "§cOff"}")) 303 + .append(Component.literal("\n")) 304 + .append(Component.literal("\n§7Use §f/atproto sync stats <on|off>")) 305 + .append(Component.literal("\n§7Use §f/atproto sync sessions <on|off>")) 306 + .append(Component.literal("\n§7Use §f/atproto sync achievements <on|off>")) 307 + .append(Component.literal("\n§7Use §f/atproto sync server-status <on|off>")) 298 308 ) 299 309 return 1 300 310 } 301 311 302 312 /** 303 - * Sets a sync consent setting (client-side convenience that directs to server). 313 + * Sets a sync consent setting. 314 + * Updates local preferences and sends the change to the server via SyncPreferencesPacket. 304 315 */ 305 316 private fun setSyncConsent(context: CommandContext<FabricClientCommandSource>): Int { 306 317 val setting = StringArgumentType.getString(context, "setting").lowercase() ··· 309 320 val parts = setting.split(" ", limit = 2) 310 321 if (parts.size != 2) { 311 322 context.source.sendError( 312 - Component.literal("§cInvalid format. Use: stats <on|off> or sessions <on|off>") 323 + Component.literal("§cInvalid format. Use: <category> <on|off>") 324 + .append(Component.literal("\n§7Categories: stats, sessions, achievements, server-status")) 313 325 ) 314 326 return 0 315 327 } ··· 317 329 val category = parts[0] 318 330 val enabled = parts[1] 319 331 320 - if (category !in listOf("stats", "sessions")) { 332 + if (category !in listOf("stats", "sessions", "achievements", "server-status")) { 321 333 context.source.sendError( 322 - Component.literal("§cUnknown category: $category. Use: stats or sessions") 334 + Component.literal("§cUnknown category: $category") 335 + .append(Component.literal("\n§7Use: stats, sessions, achievements, or server-status")) 323 336 ) 324 337 return 0 325 338 } ··· 331 344 return 0 332 345 } 333 346 334 - // Sync consent is server-side, so we inform the user 347 + val value = enabled == "on" 348 + 349 + // Update local preferences 350 + when (category) { 351 + "stats" -> PreferencesManager.updateSyncConsent(stats = value) 352 + "sessions" -> PreferencesManager.updateSyncConsent(sessions = value) 353 + "achievements" -> PreferencesManager.updateSyncConsent(achievements = value) 354 + "server-status" -> PreferencesManager.updateSyncConsent(serverStatus = value) 355 + } 356 + 357 + // Send to server 358 + val prefs = PreferencesManager.get() 359 + val packet = AtProtoPackets.SyncPreferencesPacket( 360 + syncStatsEnabled = prefs.syncStatsEnabled, 361 + syncSessionsEnabled = prefs.syncSessionsEnabled, 362 + syncAchievementsEnabled = prefs.syncAchievementsEnabled, 363 + syncServerStatusEnabled = prefs.syncServerStatusEnabled, 364 + statsSyncFrequency = prefs.statsSyncFrequency, 365 + sessionSyncFrequency = prefs.sessionSyncFrequency, 366 + achievementSyncFrequency = prefs.achievementSyncFrequency, 367 + ) 368 + ClientPlayNetworking.send(packet) 369 + 370 + val label = category.replace("-", " ") 335 371 context.source.sendFeedback( 336 - Component.literal("§eSync consent is managed on the server side.") 337 - .append(Component.literal("\n§7Run this command on the server instead:")) 338 - .append(Component.literal("\n§f/atproto sync $category $enabled")) 372 + Component.literal("§a✓ ${label.replaceFirstChar { it.uppercase() }} syncing: ${if (value) "§aOn" else "§cOff"}") 373 + .append(Component.literal("\n§7Preference saved and sent to server")) 339 374 ) 340 375 return 1 341 376 }
+91
src/client/kotlin/com/jollywhoppers/screen/AtProtoConfigScreen.kt
··· 2 2 3 3 import com.jollywhoppers.AtprotoconnectClient 4 4 import com.jollywhoppers.atproto.oauth.OAuthManager 5 + import com.jollywhoppers.config.PreferencesManager 5 6 import com.jollywhoppers.network.AtProtoPackets 6 7 import kotlinx.coroutines.CoroutineScope 7 8 import kotlinx.coroutines.Dispatchers ··· 110 111 .build() 111 112 .also { addRenderableWidget(it) } 112 113 114 + // Sync consent toggles 115 + val syncStartY = startY + 125 116 + val prefs = PreferencesManager.get() 117 + 118 + // Stats toggle 119 + addRenderableWidget( 120 + Button.builder( 121 + Component.literal("Stats: ${if (prefs.syncStatsEnabled) "§aOn" else "§cOff"}"), 122 + Button.OnPress { toggleSyncConsent("stats") } 123 + ) 124 + .bounds(centerX - 155, syncStartY, 150, 20) 125 + .build() 126 + ) 127 + 128 + // Sessions toggle 129 + addRenderableWidget( 130 + Button.builder( 131 + Component.literal("Sessions: ${if (prefs.syncSessionsEnabled) "§aOn" else "§cOff"}"), 132 + Button.OnPress { toggleSyncConsent("sessions") } 133 + ) 134 + .bounds(centerX + 5, syncStartY, 150, 20) 135 + .build() 136 + ) 137 + 138 + // Achievements toggle 139 + addRenderableWidget( 140 + Button.builder( 141 + Component.literal("Achievements: ${if (prefs.syncAchievementsEnabled) "§aOn" else "§cOff"}"), 142 + Button.OnPress { toggleSyncConsent("achievements") } 143 + ) 144 + .bounds(centerX - 155, syncStartY + 25, 150, 20) 145 + .build() 146 + ) 147 + 148 + // Server status toggle 149 + addRenderableWidget( 150 + Button.builder( 151 + Component.literal("Server Status: ${if (prefs.syncServerStatusEnabled) "§aOn" else "§cOff"}"), 152 + Button.OnPress { toggleSyncConsent("server-status") } 153 + ) 154 + .bounds(centerX + 5, syncStartY + 25, 150, 20) 155 + .build() 156 + ) 157 + 113 158 // Done button 114 159 addRenderableWidget( 115 160 Button.builder( ··· 163 208 0xA0A0A0 164 209 ) 165 210 211 + // Sync consent label 212 + graphics.drawCenteredString( 213 + font, 214 + "Sync Consent", 215 + width / 2, 216 + 178, 217 + 0xA0A0A0 218 + ) 219 + 166 220 // Render status 167 221 statusText?.let { status -> 168 222 val lines = font.split(status, width - 40) ··· 369 423 updateUIState() 370 424 371 425 logger.info("Logged out, notified server") 426 + } 427 + 428 + /** 429 + * Toggles a sync consent category and sends the change to the server. 430 + */ 431 + private fun toggleSyncConsent(category: String) { 432 + val prefs = PreferencesManager.get() 433 + val newValue = when (category) { 434 + "stats" -> !prefs.syncStatsEnabled 435 + "sessions" -> !prefs.syncSessionsEnabled 436 + "achievements" -> !prefs.syncAchievementsEnabled 437 + "server-status" -> !prefs.syncServerStatusEnabled 438 + else -> return 439 + } 440 + 441 + when (category) { 442 + "stats" -> PreferencesManager.updateSyncConsent(stats = newValue) 443 + "sessions" -> PreferencesManager.updateSyncConsent(sessions = newValue) 444 + "achievements" -> PreferencesManager.updateSyncConsent(achievements = newValue) 445 + "server-status" -> PreferencesManager.updateSyncConsent(serverStatus = newValue) 446 + } 447 + 448 + // Send updated preferences to server 449 + val updated = PreferencesManager.get() 450 + val packet = AtProtoPackets.SyncPreferencesPacket( 451 + syncStatsEnabled = updated.syncStatsEnabled, 452 + syncSessionsEnabled = updated.syncSessionsEnabled, 453 + syncAchievementsEnabled = updated.syncAchievementsEnabled, 454 + syncServerStatusEnabled = updated.syncServerStatusEnabled, 455 + statsSyncFrequency = updated.statsSyncFrequency, 456 + sessionSyncFrequency = updated.sessionSyncFrequency, 457 + achievementSyncFrequency = updated.achievementSyncFrequency, 458 + ) 459 + ClientPlayNetworking.send(packet) 460 + 461 + // Rebuild screen to update button labels 462 + rebuildWidgets() 372 463 } 373 464 374 465 private fun onHelpClicked() {
+18 -2
src/main/kotlin/com/jollywhoppers/Atprotoconnect.kt
··· 8 8 import com.jollywhoppers.atproto.server.PlayerProfileService 9 9 import com.jollywhoppers.atproto.server.PlayerSessionSyncService 10 10 import com.jollywhoppers.atproto.server.PlayerStatSyncService 11 + import com.jollywhoppers.atproto.server.PlayerSyncPreferencesStore 11 12 import com.jollywhoppers.atproto.server.RecordManager 12 13 import com.jollywhoppers.atproto.server.ServerStatusSyncService 13 14 import com.jollywhoppers.security.SecurityAuditor ··· 53 54 lateinit var serverStatusSyncService: ServerStatusSyncService 54 55 private set 55 56 57 + lateinit var syncPreferencesStore: PlayerSyncPreferencesStore 58 + private set 59 + 56 60 lateinit var commands: AtProtoCommands 57 61 private set 58 62 ··· 93 97 recordManager = RecordManager(sessionManager) 94 98 logger.info("Record manager initialized") 95 99 100 + // Initialize sync preferences store (single source of truth for consent) 101 + syncPreferencesStore = PlayerSyncPreferencesStore 102 + logger.info("Sync preferences store initialized") 103 + 104 + // Migrate legacy sync consent from PlayerIdentityStore 105 + syncPreferencesStore.migrateFromIdentityStore(identityStore) 106 + logger.info("Legacy sync consent migration completed") 107 + 96 108 // Initialize automatic Minecraft stat syncing 97 109 val statSyncStatePath = configDir.resolve("minecraft-stat-sync-state.json") 98 110 statSyncService = PlayerStatSyncService( 99 111 recordManager = recordManager, 100 112 sessionManager = sessionManager, 101 113 identityStore = identityStore, 114 + syncPreferencesStore = syncPreferencesStore, 102 115 storageFile = statSyncStatePath 103 116 ) 104 117 logger.info("Minecraft stat sync service initialized at: $statSyncStatePath") ··· 116 129 recordManager = recordManager, 117 130 sessionManager = sessionManager, 118 131 identityStore = identityStore, 132 + syncPreferencesStore = syncPreferencesStore, 119 133 ) 120 134 AchievementSyncService.INSTANCE = achievementSyncService 121 135 logger.info("Achievement sync service initialized") ··· 125 139 recordManager = recordManager, 126 140 sessionManager = sessionManager, 127 141 identityStore = identityStore, 142 + syncPreferencesStore = syncPreferencesStore, 128 143 ) 129 144 logger.info("Session sync service initialized") 130 145 ··· 133 148 recordManager = recordManager, 134 149 sessionManager = sessionManager, 135 150 identityStore = identityStore, 151 + syncPreferencesStore = syncPreferencesStore, 136 152 ) 137 153 logger.info("Server status sync service initialized") 138 154 139 155 // Initialize command handler (with rate limiting and audit logging) 140 - commands = AtProtoCommands(atProtoClient, identityStore, sessionManager, profileService) 156 + commands = AtProtoCommands(atProtoClient, identityStore, sessionManager, syncPreferencesStore, profileService) 141 157 142 158 // Register commands 143 159 CommandRegistrationCallback.EVENT.register { dispatcher, _, _ -> ··· 198 214 logger.info(" ✓ Security audit logging") 199 215 logger.info(" ✓ Enhanced SSRF protection") 200 216 logger.info(" ✓ Automatic Minecraft stat syncing") 201 - logger.info(" ✓ Sync consent controls (stats/sessions)") 217 + logger.info(" ✓ Sync consent controls (stats/sessions/achievements/server-status)") 202 218 logger.info(" ✓ Player profile record management") 203 219 logger.info(" ✓ Achievement syncing to AT Protocol") 204 220 logger.info(" ✓ Play session tracking")
+3 -3
src/main/kotlin/com/jollywhoppers/atproto/server/AchievementSyncService.kt
··· 37 37 private val recordManager: RecordManager, 38 38 private val sessionManager: AtProtoSessionManager, 39 39 private val identityStore: PlayerIdentityStore, 40 + private val syncPreferencesStore: PlayerSyncPreferencesStore, 40 41 ) { 41 42 private val logger = LoggerFactory.getLogger("atproto-connect:achievements") 42 43 private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) ··· 67 68 } 68 69 69 70 // Check sync consent 70 - val syncConsent = identityStore.getSyncConsent(uuid) 71 - if (syncConsent != null && !syncConsent.first) { 72 - logger.debug("Skipping achievement sync for ${player.name.string}: syncStats consent is disabled") 71 + if (!syncPreferencesStore.getOrDefault(uuid).shouldSync("achievements")) { 72 + logger.debug("Skipping achievement sync for ${player.name.string}: achievements sync consent is disabled") 73 73 return 74 74 } 75 75
+61 -25
src/main/kotlin/com/jollywhoppers/atproto/server/AtProtoCommands.kt
··· 31 31 private val client: AtProtoClient, 32 32 private val identityStore: PlayerIdentityStore, 33 33 private val sessionManager: AtProtoSessionManager, 34 + private val syncPreferencesStore: PlayerSyncPreferencesStore, 34 35 private val profileService: PlayerProfileService? = null, 35 36 ) { 36 37 private val logger = LoggerFactory.getLogger("atproto-connect") ··· 102 103 .then( 103 104 Commands.literal("off") 104 105 .executes { context -> setSyncConsent(context, syncSessions = false) } 106 + ) 107 + ) 108 + .then( 109 + Commands.literal("achievements") 110 + .then( 111 + Commands.literal("on") 112 + .executes { context -> setSyncConsent(context, syncAchievements = true) } 113 + ) 114 + .then( 115 + Commands.literal("off") 116 + .executes { context -> setSyncConsent(context, syncAchievements = false) } 117 + ) 118 + ) 119 + .then( 120 + Commands.literal("server-status") 121 + .then( 122 + Commands.literal("on") 123 + .executes { context -> setSyncConsent(context, syncServerStatus = true) } 124 + ) 125 + .then( 126 + Commands.literal("off") 127 + .executes { context -> setSyncConsent(context, syncServerStatus = false) } 105 128 ) 106 129 ) 107 130 ) ··· 489 512 return 0 490 513 } 491 514 492 - val syncConsent = identityStore.getSyncConsent(player.uuid) 493 - if (syncConsent == null) { 494 - context.source.sendFailure(Component.literal("§cCould not read sync consent settings")) 495 - return 0 496 - } 497 - 498 - val (syncStats, syncSessions) = syncConsent 515 + val prefs = syncPreferencesStore.getOrDefault(player.uuid) 499 516 500 517 context.source.sendSuccess( 501 518 { ··· 503 520 .append(Component.literal("\n§7Note: AT Protocol data is §falways public§7.")) 504 521 .append(Component.literal("\n§7These controls decide whether data is written at all.")) 505 522 .append(Component.literal("\n")) 506 - .append(Component.literal("\n§7Stats syncing: ${if (syncStats) "§aOn" else "§cOff"}")) 507 - .append(Component.literal("\n§7Session syncing: ${if (syncSessions) "§aOn" else "§cOff"}")) 523 + .append(Component.literal("\n§7Stats syncing: ${if (prefs.syncStatsEnabled) "§aOn" else "§cOff"}")) 524 + .append(Component.literal("\n§7Session syncing: ${if (prefs.syncSessionsEnabled) "§aOn" else "§cOff"}")) 525 + .append(Component.literal("\n§7Achievement syncing: ${if (prefs.syncAchievementsEnabled) "§aOn" else "§cOff"}")) 526 + .append(Component.literal("\n§7Server status syncing: ${if (prefs.syncServerStatusEnabled) "§aOn" else "§cOff"}")) 508 527 .append(Component.literal("\n")) 509 528 .append(Component.literal("\n§7Use §f/atproto sync stats <on|off>")) 510 529 .append(Component.literal("\n§7Use §f/atproto sync sessions <on|off>")) 530 + .append(Component.literal("\n§7Use §f/atproto sync achievements <on|off>")) 531 + .append(Component.literal("\n§7Use §f/atproto sync server-status <on|off>")) 511 532 }, 512 533 false 513 534 ) ··· 521 542 context: CommandContext<CommandSourceStack>, 522 543 syncStats: Boolean? = null, 523 544 syncSessions: Boolean? = null, 545 + syncAchievements: Boolean? = null, 546 + syncServerStatus: Boolean? = null, 524 547 ): Int { 525 548 val player = context.source.playerOrException 526 549 ··· 531 554 return 0 532 555 } 533 556 534 - val updated = identityStore.updateSyncConsent(player.uuid, syncStats, syncSessions) 535 - if (updated == null) { 536 - context.source.sendFailure(Component.literal("§cFailed to update sync consent settings")) 537 - return 0 538 - } 557 + syncPreferencesStore.update( 558 + playerId = player.uuid, 559 + stats = syncStats, 560 + sessions = syncSessions, 561 + achievements = syncAchievements, 562 + serverStatus = syncServerStatus, 563 + ) 564 + 565 + val updated = syncPreferencesStore.getOrDefault(player.uuid) 539 566 540 567 val changes = buildString { 541 568 if (syncStats != null) { 542 - append("\n§7Stats syncing: ${if (updated.syncStats) "§aOn" else "§cOff"}") 569 + append("\n§7Stats syncing: ${if (updated.syncStatsEnabled) "§aOn" else "§cOff"}") 543 570 } 544 571 if (syncSessions != null) { 545 - append("\n§7Session syncing: ${if (updated.syncSessions) "§aOn" else "§cOff"}") 572 + append("\n§7Session syncing: ${if (updated.syncSessionsEnabled) "§aOn" else "§cOff"}") 573 + } 574 + if (syncAchievements != null) { 575 + append("\n§7Achievement syncing: ${if (updated.syncAchievementsEnabled) "§aOn" else "§cOff"}") 576 + } 577 + if (syncServerStatus != null) { 578 + append("\n§7Server status syncing: ${if (updated.syncServerStatusEnabled) "§aOn" else "§cOff"}") 546 579 } 547 580 } 548 581 ··· 554 587 false 555 588 ) 556 589 557 - SecurityAuditor.logSyncConsentChange(player.uuid, player.name.string, syncStats, syncSessions) 558 - 559 - // Sync the player.profile record to reflect the change 560 - profileService?.let { service -> 561 - val server = context.source.server 562 - val serverId = buildServerId(server) 563 - val serverName = server.getMotd().ifBlank { "Minecraft Server" } 564 - service.syncProfile(player.uuid, serverId, serverName) 565 - } 590 + SecurityAuditor.logSyncPreferenceChange( 591 + playerId = player.uuid, 592 + playerName = player.name.string, 593 + stats = updated.syncStatsEnabled, 594 + sessions = updated.syncSessionsEnabled, 595 + achievements = updated.syncAchievementsEnabled, 596 + serverStatus = updated.syncServerStatusEnabled, 597 + ) 566 598 567 599 return 1 568 600 } ··· 596 628 .append(Component.literal("\n §7Control whether your stats are synced")) 597 629 .append(Component.literal("\n§f/atproto sync sessions <on|off>")) 598 630 .append(Component.literal("\n §7Control whether your sessions are synced")) 631 + .append(Component.literal("\n§f/atproto sync achievements <on|off>")) 632 + .append(Component.literal("\n §7Control whether your achievements are synced")) 633 + .append(Component.literal("\n§f/atproto sync server-status <on|off>")) 634 + .append(Component.literal("\n §7Control whether server status is synced")) 599 635 .append(Component.literal("\n")) 600 636 .append(Component.literal("\n§7Note: AT Protocol data is §falways public§7.")) 601 637 .append(Component.literal("\n§7Turning sync off prevents data from being written."))
+24 -23
src/main/kotlin/com/jollywhoppers/atproto/server/PlayerIdentityStore.kt
··· 41 41 val handle: String, 42 42 val linkedAt: Long = System.currentTimeMillis(), 43 43 val lastVerified: Long = System.currentTimeMillis(), 44 - /** Whether the player consents to syncing stats to AT Protocol. Default: true. */ 44 + // Legacy fields kept for migration deserialization only. 45 + // Sync consent is now managed by PlayerSyncPreferencesStore. 45 46 val syncStats: Boolean = true, 46 - /** Whether the player consents to syncing play sessions to AT Protocol. Default: true. */ 47 47 val syncSessions: Boolean = true, 48 48 ) 49 49 ··· 147 147 } 148 148 149 149 /** 150 - * Updates sync consent settings for a player's identity. 151 - * These are local-only controls — AT Protocol data is always public, 152 - * so the real privacy is not writing data the user doesn't want published. 153 - * @return The updated identity, or null if the player is not linked 150 + * Extracts legacy sync consent values for migration to PlayerSyncPreferencesStore. 151 + * Returns a map of UUID -> Pair(syncStats, syncSessions). 152 + * After migration, these fields are no longer authoritative. 154 153 */ 155 - fun updateSyncConsent(uuid: UUID, syncStats: Boolean? = null, syncSessions: Boolean? = null): PlayerIdentity? { 156 - val identity = identities[uuid] ?: return null 157 - val updated = identity.copy( 158 - syncStats = syncStats ?: identity.syncStats, 159 - syncSessions = syncSessions ?: identity.syncSessions, 160 - lastVerified = System.currentTimeMillis(), 161 - ) 162 - identities[uuid] = updated 163 - save() 164 - logger.info("Updated sync consent for $uuid: syncStats=${updated.syncStats}, syncSessions=${updated.syncSessions}") 165 - return updated 154 + fun extractLegacySyncConsent(): Map<UUID, Pair<Boolean, Boolean>> { 155 + return identities.entries.associate { (uuid, identity) -> 156 + uuid to Pair(identity.syncStats, identity.syncSessions) 157 + } 166 158 } 167 159 168 160 /** 169 - * Gets the sync consent settings for a player. 170 - * Returns null if the player is not linked. 171 - * Pair(syncStats, syncSessions) 161 + * Clears legacy sync consent fields from all identities after migration. 162 + * Sets syncStats and syncSessions to their defaults (true) since 163 + * PlayerSyncPreferencesStore is now the source of truth. 172 164 */ 173 - fun getSyncConsent(uuid: UUID): Pair<Boolean, Boolean>? { 174 - val identity = identities[uuid] ?: return null 175 - return Pair(identity.syncStats, identity.syncSessions) 165 + fun clearLegacySyncConsent() { 166 + var changed = false 167 + identities.forEach { (uuid, identity) -> 168 + if (!identity.syncStats || !identity.syncSessions) { 169 + identities[uuid] = identity.copy(syncStats = true, syncSessions = true) 170 + changed = true 171 + } 172 + } 173 + if (changed) { 174 + save() 175 + logger.info("Cleared legacy sync consent fields after migration") 176 + } 176 177 } 177 178 178 179 /**
+3 -3
src/main/kotlin/com/jollywhoppers/atproto/server/PlayerSessionSyncService.kt
··· 36 36 private val recordManager: RecordManager, 37 37 private val sessionManager: AtProtoSessionManager, 38 38 private val identityStore: PlayerIdentityStore, 39 + private val syncPreferencesStore: PlayerSyncPreferencesStore, 39 40 ) { 40 41 private val logger = LoggerFactory.getLogger("atproto-connect:sessions") 41 42 private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) ··· 102 103 } 103 104 104 105 // Check sync consent 105 - val syncConsent = identityStore.getSyncConsent(uuid) 106 - if (syncConsent != null && !syncConsent.second) { 107 - logger.debug("Skipping session record for $uuid: syncSessions consent is disabled") 106 + if (!syncPreferencesStore.getOrDefault(uuid).shouldSync("sessions")) { 107 + logger.debug("Skipping session record for $uuid: sessions sync consent is disabled") 108 108 return 109 109 } 110 110
+3 -3
src/main/kotlin/com/jollywhoppers/atproto/server/PlayerStatSyncService.kt
··· 26 26 private val recordManager: RecordManager, 27 27 private val sessionManager: AtProtoSessionManager, 28 28 private val identityStore: PlayerIdentityStore, 29 + private val syncPreferencesStore: PlayerSyncPreferencesStore, 29 30 storageFile: Path, 30 31 private val syncIntervalTicks: Long = 20L * 60L * 5L 31 32 ) { ··· 116 117 } 117 118 118 119 // Check sync consent before syncing stats 119 - val syncConsent = identityStore.getSyncConsent(player.uuid) 120 - if (syncConsent != null && !syncConsent.first) { 121 - logger.debug("Skipping stat sync for ${player.name.string}: syncStats consent is disabled") 120 + if (!syncPreferencesStore.getOrDefault(player.uuid).shouldSync("stats")) { 121 + logger.debug("Skipping stat sync for ${player.name.string}: stats sync consent is disabled") 122 122 return null 123 123 } 124 124
+38
src/main/kotlin/com/jollywhoppers/atproto/server/PlayerSyncPreferencesStore.kt
··· 163 163 } 164 164 165 165 /** 166 + * Migrate legacy sync consent from PlayerIdentityStore. 167 + * Called once during mod initialization. Reads the old syncStats/syncSessions 168 + * values and writes them into the preferences store for any player that 169 + * doesn't already have a preferences file. 170 + */ 171 + fun migrateFromIdentityStore(identityStore: PlayerIdentityStore) { 172 + val legacyConsent = identityStore.extractLegacySyncConsent() 173 + var migrated = 0 174 + 175 + legacyConsent.forEach { (uuid, consent) -> 176 + val (syncStats, syncSessions) = consent 177 + val existing = getOrDefault(uuid) 178 + 179 + // Only migrate if the player has default preferences (never customised) 180 + // and the legacy values differ from defaults 181 + val isDefault = existing.syncStatsEnabled && existing.syncSessionsEnabled 182 + && existing.syncAchievementsEnabled && !existing.syncServerStatusEnabled 183 + val hasNonDefaultLegacy = !syncStats || !syncSessions 184 + 185 + if (isDefault && hasNonDefaultLegacy) { 186 + save(existing.copy( 187 + syncStatsEnabled = syncStats, 188 + syncSessionsEnabled = syncSessions, 189 + )) 190 + migrated++ 191 + logger.info("Migrated sync consent for player $uuid: stats=$syncStats, sessions=$syncSessions") 192 + } 193 + } 194 + 195 + // Clear the legacy fields from the identity store 196 + identityStore.clearLegacySyncConsent() 197 + 198 + if (migrated > 0) { 199 + logger.info("Migrated sync consent for $migrated players from identity store") 200 + } 201 + } 202 + 203 + /** 166 204 * Get all players with preferences (for admin operations) 167 205 */ 168 206 fun getAllPlayerIds(): List<UUID> {
+6 -3
src/main/kotlin/com/jollywhoppers/atproto/server/ServerStatusSyncService.kt
··· 32 32 private val recordManager: RecordManager, 33 33 private val sessionManager: AtProtoSessionManager, 34 34 private val identityStore: PlayerIdentityStore, 35 + private val syncPreferencesStore: PlayerSyncPreferencesStore, 35 36 ) { 36 37 private val logger = LoggerFactory.getLogger("atproto-connect:server-status") 37 38 private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) ··· 48 49 fun onSyncTick(server: MinecraftServer) { 49 50 if (!server.isRunning || server.isStopped) return 50 51 51 - // Find an authenticated player to use their session 52 + // Find an authenticated player who has consented to server status syncing 52 53 val authenticatedPlayer = server.playerList.players.firstOrNull { player -> 53 - identityStore.isLinked(player.uuid) && sessionManager.hasSession(player.uuid) 54 + identityStore.isLinked(player.uuid) 55 + && sessionManager.hasSession(player.uuid) 56 + && syncPreferencesStore.getOrDefault(player.uuid).shouldSync("server_status") 54 57 } 55 58 56 59 if (authenticatedPlayer == null) { 57 - logger.debug("Skipping server status sync: no authenticated players online") 60 + logger.debug("Skipping server status sync: no authenticated players with server_status consent online") 58 61 return 59 62 } 60 63
-8
src/main/kotlin/com/jollywhoppers/security/SecurityAuditor.kt
··· 58 58 log("SUSPICIOUS", uuid, message, ip) 59 59 } 60 60 61 - fun logSyncConsentChange(uuid: UUID, playerName: String, syncStats: Boolean? = null, syncSessions: Boolean? = null) { 62 - val changes = buildString { 63 - if (syncStats != null) append(" syncStats=$syncStats") 64 - if (syncSessions != null) append(" syncSessions=$syncSessions") 65 - } 66 - log("SYNC_CONSENT_CHANGE", uuid, "Player $playerName changed sync consent:$changes", null) 67 - } 68 - 69 61 fun logSyncPreferenceChange( 70 62 playerId: UUID, 71 63 playerName: String,