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(privacy): reframe privacy controls as sync consent

AT Protocol data is always public by design, so "publicStats"/"publicSessions"
flags in the profile record were misleading. Renamed to "syncStats"/"syncSessions"
to clarify that these control whether data is written at all, not who can see it.

Changes:
- Remove publicStats/publicSessions from player.profile lexicon
- Rename fields to syncStats/syncSessions in PlayerIdentityStore
- Rename /atproto privacy to /atproto sync with clearer language
- Update all services to use getSyncConsent() instead of getPrivacySettings()
- Update client commands to match

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

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

+107 -105
+26 -26
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) } 66 + ClientCommandManager.literal("sync") 67 + .executes { context -> syncConsentStatus(context) } 68 68 .then( 69 69 ClientCommandManager.argument("setting", StringArgumentType.string()) 70 70 .suggests { _, builder -> 71 - builder.suggest("stats public") 72 - builder.suggest("stats private") 73 - builder.suggest("sessions public") 74 - builder.suggest("sessions private") 71 + builder.suggest("stats on") 72 + builder.suggest("stats off") 73 + builder.suggest("sessions on") 74 + builder.suggest("sessions off") 75 75 builder.buildFuture() 76 76 } 77 - .executes { context -> setPrivacy(context) } 77 + .executes { context -> setSyncConsent(context) } 78 78 ) 79 79 ) 80 80 .then( ··· 270 270 } 271 271 272 272 /** 273 - * Shows current privacy settings. 274 - * Note: Privacy settings are stored server-side, so this shows the 273 + * Shows current sync consent settings. 274 + * Note: Sync consent settings are stored server-side, so this shows the 275 275 * current session's auth type and reminds the user to use server commands. 276 276 */ 277 - private fun privacyStatus(context: CommandContext<FabricClientCommandSource>): Int { 277 + private fun syncConsentStatus(context: CommandContext<FabricClientCommandSource>): Int { 278 278 val hasSession = sessionManager.hasSession() 279 279 val isOAuth = sessionManager.isOAuthSession() 280 280 281 281 context.source.sendFeedback( 282 - Component.literal("§b━━━ Privacy Settings ━━━") 282 + Component.literal("§b━━━ Sync Consent ━━━") 283 + .append(Component.literal("\n§7Note: AT Protocol data is §falways public§7.")) 284 + .append(Component.literal("\n§7Sync consent controls whether data is written at all.")) 283 285 .append( 284 286 if (hasSession) { 285 287 Component.literal("\n§7Auth type: §f${if (isOAuth) "OAuth" else "App Password"}") 286 288 .append(Component.literal("\n§7OAuth sessions use scoped permissions")) 287 - .append(Component.literal("\n§7for better privacy control")) 288 289 } else { 289 290 Component.literal("\n§cNot logged in") 290 291 } 291 292 ) 292 293 .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>")) 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>")) 298 298 ) 299 299 return 1 300 300 } 301 301 302 302 /** 303 - * Sets a privacy setting (client-side convenience that sends to server). 303 + * Sets a sync consent setting (client-side convenience that directs to server). 304 304 */ 305 - private fun setPrivacy(context: CommandContext<FabricClientCommandSource>): Int { 305 + private fun setSyncConsent(context: CommandContext<FabricClientCommandSource>): Int { 306 306 val setting = StringArgumentType.getString(context, "setting").lowercase() 307 307 308 308 // Parse the setting string 309 309 val parts = setting.split(" ", limit = 2) 310 310 if (parts.size != 2) { 311 311 context.source.sendError( 312 - Component.literal("§cInvalid format. Use: stats <public|private> or sessions <public|private>") 312 + Component.literal("§cInvalid format. Use: stats <on|off> or sessions <on|off>") 313 313 ) 314 314 return 0 315 315 } 316 316 317 317 val category = parts[0] 318 - val visibility = parts[1] 318 + val enabled = parts[1] 319 319 320 320 if (category !in listOf("stats", "sessions")) { 321 321 context.source.sendError( ··· 324 324 return 0 325 325 } 326 326 327 - if (visibility !in listOf("public", "private")) { 327 + if (enabled !in listOf("on", "off")) { 328 328 context.source.sendError( 329 - Component.literal("§cUnknown visibility: $visibility. Use: public or private") 329 + Component.literal("§cUnknown value: $enabled. Use: on or off") 330 330 ) 331 331 return 0 332 332 } 333 333 334 - // Privacy settings are server-side, so we inform the user 334 + // Sync consent is server-side, so we inform the user 335 335 context.source.sendFeedback( 336 - Component.literal("§ePrivacy settings are managed on the server side.") 336 + Component.literal("§eSync consent is managed on the server side.") 337 337 .append(Component.literal("\n§7Run this command on the server instead:")) 338 - .append(Component.literal("\n§f/atproto privacy $category $visibility")) 338 + .append(Component.literal("\n§f/atproto sync $category $enabled")) 339 339 ) 340 340 return 1 341 341 }
+4 -4
src/main/kotlin/com/jollywhoppers/atproto/server/AchievementSyncService.kt
··· 66 66 return 67 67 } 68 68 69 - // Check privacy setting 70 - val privacySettings = identityStore.getPrivacySettings(uuid) 71 - if (privacySettings != null && !privacySettings.first) { 72 - logger.debug("Skipping achievement sync for ${player.name.string}: publicStats is disabled") 69 + // 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") 73 73 return 74 74 } 75 75
+48 -40
src/main/kotlin/com/jollywhoppers/atproto/server/AtProtoCommands.kt
··· 80 80 .executes { context -> status(context) } 81 81 ) 82 82 .then( 83 - Commands.literal("privacy") 84 - .executes { context -> privacyStatus(context) } 83 + Commands.literal("sync") 84 + .executes { context -> syncConsentStatus(context) } 85 85 .then( 86 86 Commands.literal("stats") 87 87 .then( 88 - Commands.literal("public") 89 - .executes { context -> setPrivacy(context, publicStats = true) } 88 + Commands.literal("on") 89 + .executes { context -> setSyncConsent(context, syncStats = true) } 90 90 ) 91 91 .then( 92 - Commands.literal("private") 93 - .executes { context -> setPrivacy(context, publicStats = false) } 92 + Commands.literal("off") 93 + .executes { context -> setSyncConsent(context, syncStats = false) } 94 94 ) 95 95 ) 96 96 .then( 97 97 Commands.literal("sessions") 98 98 .then( 99 - Commands.literal("public") 100 - .executes { context -> setPrivacy(context, publicSessions = true) } 99 + Commands.literal("on") 100 + .executes { context -> setSyncConsent(context, syncSessions = true) } 101 101 ) 102 102 .then( 103 - Commands.literal("private") 104 - .executes { context -> setPrivacy(context, publicSessions = false) } 103 + Commands.literal("off") 104 + .executes { context -> setSyncConsent(context, syncSessions = false) } 105 105 ) 106 106 ) 107 107 ) ··· 474 474 } 475 475 476 476 /** 477 - * Shows current privacy settings. 477 + * Shows current sync consent settings. 478 + * AT Protocol data is always public — these controls determine whether 479 + * data is written to AT Protocol at all. 478 480 */ 479 - private fun privacyStatus(context: CommandContext<CommandSourceStack>): Int { 481 + private fun syncConsentStatus(context: CommandContext<CommandSourceStack>): Int { 480 482 val player = context.source.playerOrException 481 483 482 484 if (!identityStore.isLinked(player.uuid)) { ··· 487 489 return 0 488 490 } 489 491 490 - val privacySettings = identityStore.getPrivacySettings(player.uuid) 491 - if (privacySettings == null) { 492 - context.source.sendFailure(Component.literal("§cCould not read privacy settings")) 492 + val syncConsent = identityStore.getSyncConsent(player.uuid) 493 + if (syncConsent == null) { 494 + context.source.sendFailure(Component.literal("§cCould not read sync consent settings")) 493 495 return 0 494 496 } 495 497 496 - val (publicStats, publicSessions) = privacySettings 498 + val (syncStats, syncSessions) = syncConsent 497 499 498 500 context.source.sendSuccess( 499 501 { 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"}")) 502 + Component.literal("§b━━━ Sync Consent ━━━") 503 + .append(Component.literal("\n§7Note: AT Protocol data is §falways public§7.")) 504 + .append(Component.literal("\n§7These controls decide whether data is written at all.")) 503 505 .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 + .append(Component.literal("\n§7Stats syncing: ${if (syncStats) "§aOn" else "§cOff"}")) 507 + .append(Component.literal("\n§7Session syncing: ${if (syncSessions) "§aOn" else "§cOff"}")) 508 + .append(Component.literal("\n")) 509 + .append(Component.literal("\n§7Use §f/atproto sync stats <on|off>")) 510 + .append(Component.literal("\n§7Use §f/atproto sync sessions <on|off>")) 506 511 }, 507 512 false 508 513 ) ··· 510 515 } 511 516 512 517 /** 513 - * Sets a privacy setting. 518 + * Sets a sync consent setting. 514 519 */ 515 - private fun setPrivacy( 520 + private fun setSyncConsent( 516 521 context: CommandContext<CommandSourceStack>, 517 - publicStats: Boolean? = null, 518 - publicSessions: Boolean? = null, 522 + syncStats: Boolean? = null, 523 + syncSessions: Boolean? = null, 519 524 ): Int { 520 525 val player = context.source.playerOrException 521 526 ··· 526 531 return 0 527 532 } 528 533 529 - val updated = identityStore.updatePrivacy(player.uuid, publicStats, publicSessions) 534 + val updated = identityStore.updateSyncConsent(player.uuid, syncStats, syncSessions) 530 535 if (updated == null) { 531 - context.source.sendFailure(Component.literal("§cFailed to update privacy settings")) 536 + context.source.sendFailure(Component.literal("§cFailed to update sync consent settings")) 532 537 return 0 533 538 } 534 539 535 540 val changes = buildString { 536 - if (publicStats != null) { 537 - append("\n§7Stats: ${if (updated.publicStats) "§aPublic" else "§cPrivate"}") 541 + if (syncStats != null) { 542 + append("\n§7Stats syncing: ${if (updated.syncStats) "§aOn" else "§cOff"}") 538 543 } 539 - if (publicSessions != null) { 540 - append("\n§7Sessions: ${if (updated.publicSessions) "§aPublic" else "§cPrivate"}") 544 + if (syncSessions != null) { 545 + append("\n§7Session syncing: ${if (updated.syncSessions) "§aOn" else "§cOff"}") 541 546 } 542 547 } 543 548 544 549 context.source.sendSuccess( 545 550 { 546 - Component.literal("§a✓ Privacy settings updated") 551 + Component.literal("§a✓ Sync consent updated") 547 552 .append(Component.literal(changes)) 548 553 }, 549 554 false 550 555 ) 551 556 552 - SecurityAuditor.logPrivacyChange(player.uuid, player.name.string, publicStats, publicSessions) 557 + SecurityAuditor.logSyncConsentChange(player.uuid, player.name.string, syncStats, syncSessions) 553 558 554 - // Sync the player.profile record to reflect the privacy change 559 + // Sync the player.profile record to reflect the change 555 560 profileService?.let { service -> 556 561 val server = context.source.server 557 562 val serverId = buildServerId(server) ··· 585 590 .append(Component.literal("\n§f/atproto status")) 586 591 .append(Component.literal("\n §7Check connection status")) 587 592 .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")) 593 + .append(Component.literal("\n§f/atproto sync")) 594 + .append(Component.literal("\n §7View your sync consent settings")) 595 + .append(Component.literal("\n§f/atproto sync stats <on|off>")) 596 + .append(Component.literal("\n §7Control whether your stats are synced")) 597 + .append(Component.literal("\n§f/atproto sync sessions <on|off>")) 598 + .append(Component.literal("\n §7Control whether your sessions are synced")) 599 + .append(Component.literal("\n")) 600 + .append(Component.literal("\n§7Note: AT Protocol data is §falways public§7.")) 601 + .append(Component.literal("\n§7Turning sync off prevents data from being written.")) 594 602 .append(Component.literal("\n")) 595 603 .append(Component.literal("\n§f/atproto whois <player or handle>")) 596 604 .append(Component.literal("\n §7Look up another player's AT Protocol identity"))
+15 -10
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 - val publicStats: Boolean = true, 45 - val publicSessions: Boolean = true, 44 + /** Whether the player consents to syncing stats to AT Protocol. Default: true. */ 45 + val syncStats: Boolean = true, 46 + /** Whether the player consents to syncing play sessions to AT Protocol. Default: true. */ 47 + val syncSessions: Boolean = true, 46 48 ) 47 49 48 50 @Serializable ··· 145 147 } 146 148 147 149 /** 148 - * Updates privacy settings for a player's identity. 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. 149 153 * @return The updated identity, or null if the player is not linked 150 154 */ 151 - fun updatePrivacy(uuid: UUID, publicStats: Boolean? = null, publicSessions: Boolean? = null): PlayerIdentity? { 155 + fun updateSyncConsent(uuid: UUID, syncStats: Boolean? = null, syncSessions: Boolean? = null): PlayerIdentity? { 152 156 val identity = identities[uuid] ?: return null 153 157 val updated = identity.copy( 154 - publicStats = publicStats ?: identity.publicStats, 155 - publicSessions = publicSessions ?: identity.publicSessions, 158 + syncStats = syncStats ?: identity.syncStats, 159 + syncSessions = syncSessions ?: identity.syncSessions, 156 160 lastVerified = System.currentTimeMillis(), 157 161 ) 158 162 identities[uuid] = updated 159 163 save() 160 - logger.info("Updated privacy settings for $uuid: publicStats=${updated.publicStats}, publicSessions=${updated.publicSessions}") 164 + logger.info("Updated sync consent for $uuid: syncStats=${updated.syncStats}, syncSessions=${updated.syncSessions}") 161 165 return updated 162 166 } 163 167 164 168 /** 165 - * Gets the privacy settings for a player. 169 + * Gets the sync consent settings for a player. 166 170 * Returns null if the player is not linked. 171 + * Pair(syncStats, syncSessions) 167 172 */ 168 - fun getPrivacySettings(uuid: UUID): Pair<Boolean, Boolean>? { 173 + fun getSyncConsent(uuid: UUID): Pair<Boolean, Boolean>? { 169 174 val identity = identities[uuid] ?: return null 170 - return Pair(identity.publicStats, identity.publicSessions) 175 + return Pair(identity.syncStats, identity.syncSessions) 171 176 } 172 177 173 178 /**
+5 -6
src/main/kotlin/com/jollywhoppers/atproto/server/PlayerProfileService.kt
··· 17 17 * 18 18 * When a player links their identity, this service creates or updates their 19 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. 20 + * 21 + * Note: AT Protocol data is always public. Sync consent (syncStats, syncSessions) 22 + * is stored locally in PlayerIdentityStore and controls whether data is written 23 + * at all — it is NOT included in the profile record since it would give a false 24 + * sense of privacy control. 22 25 */ 23 26 class PlayerProfileService( 24 27 private val recordManager: RecordManager, ··· 72 75 serverName = serverName, 73 76 serverAddress = serverAddress, 74 77 ), 75 - publicStats = identity.publicStats, 76 - publicSessions = identity.publicSessions, 77 78 createdAt = Instant.ofEpochMilli(identity.linkedAt).toString(), 78 79 updatedAt = Instant.now().toString(), 79 80 ) ··· 114 115 @SerialName("\$type") val type: String = COLLECTION_ID, 115 116 val player: PlayerReference, 116 117 val primaryServer: ServerReference? = null, 117 - val publicStats: Boolean = true, 118 - val publicSessions: Boolean = true, 119 118 val createdAt: String, 120 119 val updatedAt: String, 121 120 )
+4 -4
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") 118 + // 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") 122 122 return null 123 123 } 124 124
+4 -4
src/main/kotlin/com/jollywhoppers/security/SecurityAuditor.kt
··· 58 58 log("SUSPICIOUS", uuid, message, ip) 59 59 } 60 60 61 - fun logPrivacyChange(uuid: UUID, playerName: String, publicStats: Boolean? = null, publicSessions: Boolean? = null) { 61 + fun logSyncConsentChange(uuid: UUID, playerName: String, syncStats: Boolean? = null, syncSessions: Boolean? = null) { 62 62 val changes = buildString { 63 - if (publicStats != null) append(" publicStats=$publicStats") 64 - if (publicSessions != null) append(" publicSessions=$publicSessions") 63 + if (syncStats != null) append(" syncStats=$syncStats") 64 + if (syncSessions != null) append(" syncSessions=$syncSessions") 65 65 } 66 - log("PRIVACY_CHANGE", uuid, "Player $playerName changed privacy settings:$changes", null) 66 + log("SYNC_CONSENT_CHANGE", uuid, "Player $playerName changed sync consent:$changes", null) 67 67 } 68 68 69 69 private fun log(event: String, uuid: UUID?, message: String, ip: String?) {
+1 -11
src/main/resources/lexicons/com.jollywhoppers.minecraft.player.profile.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", 7 - "description": "Links a Minecraft player identity to an AT Protocol account. This is the primary identity record.", 7 + "description": "Links a Minecraft player identity to an AT Protocol account. This is the primary identity record. Note: all AT Protocol data is public by design — sync consent is managed locally, not via record fields.", 8 8 "key": "literal:self", 9 9 "record": { 10 10 "type": "object", ··· 46 46 "type": "string", 47 47 "format": "datetime", 48 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 49 } 60 50 } 61 51 }