From 8d43d668eb73bba2758a62157996d5c55a757dcb Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Sun, 12 Jul 2026 21:02:01 +0200 Subject: [PATCH 1/3] feat(presence): publish the network-wide player lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plugin-proxy's ProxyService resolves a player locally and then falls back to a PlayerSessionQuery — but nothing ever registered one, so the fallback returned null and a proxy could only ever see its own players. That is why /msg and party invites did not cross proxies. Register one, backed by service-player (contract 0.3.0): name -> uuid, uuid -> session, and prefix suggestions for tab-complete. Every lookup fails soft — an unavailable presence service means "unknown", and the caller falls back to the players it can see locally, rather than an exception on Velocity's event loop. Login now carries the player's name and PROXY_ID: without the name a session exists but nobody can address it, and ServerConnectedEvent keeps the session pointing at the backend the player is actually on. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01E92vTegZqXd2HqjE7jnDJz --- common/build.gradle.kts | 2 +- .../presence/GrpcPlayerPresenceClient.kt | 77 ++++++++++++++++++- velocity/build.gradle.kts | 13 ++++ .../kotlin/gg/grounds/GroundsPluginPlayer.kt | 15 ++++ .../listener/PlayerConnectionListener.kt | 18 ++++- .../grounds/presence/PlayerPresenceService.kt | 41 +++++++++- .../presence/PlayerSessionQueryImpl.kt | 41 ++++++++++ 7 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 velocity/src/main/kotlin/gg/grounds/presence/PlayerSessionQueryImpl.kt diff --git a/common/build.gradle.kts b/common/build.gradle.kts index f783fec..c157fe9 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -10,4 +10,4 @@ repositories { } } -dependencies { protobuf("gg.grounds:library-grpc-contracts-player:0.2.0") } +dependencies { protobuf("gg.grounds:library-grpc-contracts-player:0.3.0") } diff --git a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt b/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt index 680006f..35e7c71 100644 --- a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt +++ b/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt @@ -1,11 +1,16 @@ package gg.grounds.player.presence +import gg.grounds.grpc.player.GetPlayerSessionRequest import gg.grounds.grpc.player.PlayerHeartbeatBatchReply import gg.grounds.grpc.player.PlayerHeartbeatBatchRequest import gg.grounds.grpc.player.PlayerLoginRequest import gg.grounds.grpc.player.PlayerLogoutReply import gg.grounds.grpc.player.PlayerLogoutRequest import gg.grounds.grpc.player.PlayerPresenceServiceGrpc +import gg.grounds.grpc.player.PlayerSessionInfo +import gg.grounds.grpc.player.ResolvePlayerNameRequest +import gg.grounds.grpc.player.SuggestPlayerNamesRequest +import gg.grounds.grpc.player.UpdatePlayerServerRequest import io.grpc.ManagedChannel import io.grpc.ManagedChannelBuilder import io.grpc.Status @@ -18,13 +23,17 @@ private constructor( private val channel: ManagedChannel, private val stub: PlayerPresenceServiceGrpc.PlayerPresenceServiceBlockingStub, ) : AutoCloseable { - fun tryLogin(playerId: UUID): PlayerLoginResult { + fun tryLogin(playerId: UUID, playerName: String = "", proxyId: String = ""): PlayerLoginResult { return try { val reply = stub .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) .tryPlayerLogin( - PlayerLoginRequest.newBuilder().setPlayerId(playerId.toString()).build() + PlayerLoginRequest.newBuilder() + .setPlayerId(playerId.toString()) + .setPlayerName(playerName) + .setProxyId(proxyId) + .build() ) PlayerLoginResult.Success(reply) } catch (e: StatusRuntimeException) { @@ -67,6 +76,70 @@ private constructor( } } + /** + * The lookups below back cross-proxy features, and they run on the command path (`/msg`, + * tab-complete). A failure means "I don't know", never an exception into Velocity's event loop + * — the caller then falls back to what it can see locally. + */ + fun getSession(playerId: UUID): PlayerSessionInfo? { + return try { + val reply = + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .getPlayerSession( + GetPlayerSessionRequest.newBuilder() + .setPlayerId(playerId.toString()) + .build() + ) + if (reply.found) reply.session else null + } catch (e: RuntimeException) { + null + } + } + + fun resolveName(playerName: String): PlayerSessionInfo? { + return try { + val reply = + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .resolvePlayerName( + ResolvePlayerNameRequest.newBuilder().setPlayerName(playerName).build() + ) + if (reply.found) reply.session else null + } catch (e: RuntimeException) { + null + } + } + + fun suggestNames(prefix: String, limit: Int): List { + return try { + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .suggestPlayerNames( + SuggestPlayerNamesRequest.newBuilder().setPrefix(prefix).setLimit(limit).build() + ) + .playerNamesList + } catch (e: RuntimeException) { + emptyList() + } + } + + fun updateServer(playerId: UUID, serverName: String): Boolean { + return try { + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .updatePlayerServer( + UpdatePlayerServerRequest.newBuilder() + .setPlayerId(playerId.toString()) + .setServerName(serverName) + .build() + ) + .updated + } catch (e: RuntimeException) { + false + } + } + override fun close() { channel.shutdown() try { diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 403cf89..9a284cd 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -1,7 +1,20 @@ plugins { id("gg.grounds.velocity-conventions") } +repositories { + maven { + url = uri("https://maven.pkg.github.com/groundsgg/*") + credentials { + username = providers.gradleProperty("github.user").get() + password = providers.gradleProperty("github.token").get() + } + } +} + dependencies { implementation(project(":common")) + // plugin-proxy owns the ProxyServiceRegistry at runtime — compileOnly, never shaded, or the + // registry this plugin writes into would be a different class from the one chat/social read. + compileOnly("gg.grounds:plugin-proxy-api:0.1.0") implementation("tools.jackson.dataformat:jackson-dataformat-yaml:3.0.4") implementation("tools.jackson.module:jackson-module-kotlin:3.0.4") implementation("io.grpc:grpc-netty-shaded:1.78.0") diff --git a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt index c56e123..b277c8c 100644 --- a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt +++ b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt @@ -4,6 +4,7 @@ import com.google.inject.Inject import com.velocitypowered.api.event.Subscribe import com.velocitypowered.api.event.proxy.ProxyInitializeEvent import com.velocitypowered.api.event.proxy.ProxyShutdownEvent +import com.velocitypowered.api.plugin.Dependency import com.velocitypowered.api.plugin.Plugin import com.velocitypowered.api.plugin.annotation.DataDirectory import com.velocitypowered.api.proxy.ProxyServer @@ -11,6 +12,9 @@ import gg.grounds.config.MessagesConfigLoader import gg.grounds.listener.PlayerConnectionListener import gg.grounds.presence.PlayerHeartbeatScheduler import gg.grounds.presence.PlayerPresenceService +import gg.grounds.presence.PlayerSessionQueryImpl +import gg.grounds.proxy.api.PlayerSessionQuery +import gg.grounds.proxy.api.ProxyServiceRegistry import io.grpc.LoadBalancerRegistry import io.grpc.NameResolverRegistry import io.grpc.internal.DnsNameResolverProvider @@ -25,6 +29,8 @@ import org.slf4j.Logger description = "A plugin which manages player related actions and data transactions", authors = ["Grounds Development Team and contributors"], url = "https://github.com/groundsgg/plugin-player", + // plugin-proxy owns the ProxyServiceRegistry this plugin publishes its session lookup into. + dependencies = [Dependency(id = "plugin-proxy")], ) class GroundsPluginPlayer @Inject @@ -58,12 +64,21 @@ constructor( ), ) + // Publish the network-wide player lookup. plugin-proxy's ProxyService falls back to this + // for + // anyone who is not on this proxy — it is what lets chat and social reach across proxies. + ProxyServiceRegistry.register( + PlayerSessionQuery::class.java, + PlayerSessionQueryImpl(playerPresenceService), + ) + heartbeatScheduler.start() logger.info("Configured player presence gRPC client (target={})", target) } @Subscribe fun onShutdown(event: ProxyShutdownEvent) { + ProxyServiceRegistry.unregister(PlayerSessionQuery::class.java) heartbeatScheduler.stop() playerPresenceService.close() } diff --git a/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt b/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt index be56f94..ae7ef70 100644 --- a/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt +++ b/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt @@ -4,6 +4,7 @@ import com.velocitypowered.api.event.EventTask import com.velocitypowered.api.event.Subscribe import com.velocitypowered.api.event.connection.DisconnectEvent import com.velocitypowered.api.event.connection.PreLoginEvent +import com.velocitypowered.api.event.player.ServerConnectedEvent import com.velocitypowered.api.util.UuidUtils import gg.grounds.config.MessagesConfig import gg.grounds.grpc.player.LoginStatus @@ -25,7 +26,9 @@ class PlayerConnectionListener( val playerId = event.uniqueId ?: UuidUtils.generateOfflinePlayerUuid(name) return EventTask.async { - when (val result = playerPresenceService.tryLogin(playerId)) { + // The name is what makes the session findable from another proxy — without it a player + // exists in presence but nobody can /msg them. PROXY_ID says which proxy holds them. + when (val result = playerPresenceService.tryLogin(playerId, name, proxyId())) { is PlayerLoginResult.Success -> { if (handleSuccess(event, name, playerId, result.reply)) { return@async @@ -117,4 +120,17 @@ class PlayerConnectionListener( private fun deny(event: PreLoginEvent, message: String) { event.result = PreLoginEvent.PreLoginComponentResult.denied(Component.text(message)) } + + /** + * Keep the session pointing at the backend the player is actually on — a party warp wants to + * send someone to the leader's server, and that only works if we know where people are. + */ + @Subscribe + fun onServerConnected(event: ServerConnectedEvent): EventTask { + val playerId = event.player.uniqueId + val serverName = event.server.serverInfo.name + return EventTask.async { playerPresenceService.updateServer(playerId, serverName) } + } + + private fun proxyId(): String = System.getenv("PROXY_ID") ?: "" } diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt index 2ccd3c1..543f96d 100644 --- a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt @@ -1,6 +1,7 @@ package gg.grounds.presence import gg.grounds.grpc.player.PlayerLogoutReply +import gg.grounds.grpc.player.PlayerSessionInfo import gg.grounds.player.presence.GrpcPlayerPresenceClient import gg.grounds.player.presence.PlayerLoginResult import java.util.UUID @@ -20,14 +21,50 @@ class PlayerPresenceService : AutoCloseable { client = GrpcPlayerPresenceClient.create(target) } - fun tryLogin(playerId: UUID): PlayerLoginResult { + fun tryLogin(playerId: UUID, playerName: String, proxyId: String): PlayerLoginResult { return try { - client.tryLogin(playerId) + client.tryLogin(playerId, playerName, proxyId) } catch (e: RuntimeException) { PlayerLoginResult.Error(e.message ?: e::class.java.name) } } + /** + * Cross-proxy lookups. Never throw: a failure means "unknown", and the caller falls back to + * local. + */ + fun getSession(playerId: UUID): PlayerSessionInfo? { + return try { + client.getSession(playerId) + } catch (e: RuntimeException) { + null + } + } + + fun resolveName(playerName: String): PlayerSessionInfo? { + return try { + client.resolveName(playerName) + } catch (e: RuntimeException) { + null + } + } + + fun suggestNames(prefix: String, limit: Int): List { + return try { + client.suggestNames(prefix, limit) + } catch (e: RuntimeException) { + emptyList() + } + } + + fun updateServer(playerId: UUID, serverName: String): Boolean { + return try { + client.updateServer(playerId, serverName) + } catch (e: RuntimeException) { + false + } + } + fun logout(playerId: UUID): PlayerLogoutReply? { return try { client.logout(playerId) diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerSessionQueryImpl.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerSessionQueryImpl.kt new file mode 100644 index 0000000..9dd3483 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerSessionQueryImpl.kt @@ -0,0 +1,41 @@ +package gg.grounds.presence + +import gg.grounds.proxy.api.PlayerSessionInfo +import gg.grounds.proxy.api.PlayerSessionQuery +import java.util.UUID + +/** + * Answers "who is this player, and are they online" for the whole network, from service-player. + * + * plugin-proxy's ProxyService resolves locally first and falls back to whatever is registered here; + * without this, a proxy could only ever see its own players, which is why /msg and party invites + * did not cross proxies. + */ +class PlayerSessionQueryImpl(private val presenceService: PlayerPresenceService) : + PlayerSessionQuery { + + override fun getSession(playerId: UUID): PlayerSessionInfo? = + presenceService.getSession(playerId)?.let(::toInfo) + + override fun resolveByName(name: String): PlayerSessionInfo? = + presenceService.resolveName(name)?.let(::toInfo) + + override fun suggestNames(prefix: String, limit: Int): List = + presenceService.suggestNames(prefix, limit) + + /** + * A session with no usable id or name tells the caller nothing — drop it rather than + * half-answer. + */ + private fun toInfo(session: gg.grounds.grpc.player.PlayerSessionInfo): PlayerSessionInfo? { + val playerId = runCatching { UUID.fromString(session.playerId) }.getOrNull() ?: return null + val name = session.playerName.takeIf { it.isNotEmpty() } ?: return null + return PlayerSessionInfo( + playerId = playerId, + name = name, + proxyId = session.proxyId.takeIf { it.isNotEmpty() }, + server = session.serverName.takeIf { it.isNotEmpty() }, + connectedAt = session.connectedAtMillis, + ) + } +} From 5c78aa6d29754459af6b2d80673d35cc8b0348d6 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Sun, 12 Jul 2026 21:05:22 +0200 Subject: [PATCH 2/3] ci: publish the plugin image to ghcr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plugin-player has never shipped an image, so the platform bundle could not deploy it — and without it in the proxy, nothing registers the session lookup that cross-proxy chat and social depend on. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01E92vTegZqXd2HqjE7jnDJz --- .../workflows/docker-gradle-build-push.yml | 22 +++++++++ Dockerfile | 49 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 .github/workflows/docker-gradle-build-push.yml create mode 100644 Dockerfile diff --git a/.github/workflows/docker-gradle-build-push.yml b/.github/workflows/docker-gradle-build-push.yml new file mode 100644 index 0000000..09fb424 --- /dev/null +++ b/.github/workflows/docker-gradle-build-push.yml @@ -0,0 +1,22 @@ +name: Docker Build + +# Publishes ghcr.io/groundsgg/plugin-player (edge on main, semver on tag). +# The image carries the shaded JAR at /jar/plugin.jar so the platform bundle +# can pull the plugin into the velocity proxy via the plugin-velocity-jar chart. + +on: + push: + branches: + - main + tags: + - "v*" + pull_request: + +permissions: + packages: write + contents: write + actions: write + +jobs: + reusable: + uses: groundsgg/.github/.github/workflows/docker-gradle-build-push.yml@main diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6b5b7d4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# syntax=docker/dockerfile:1 +# +# Builds the plugin-player Velocity-plugin JAR and packages it for the +# platform-test environment. The output image carries the JAR at +# /jar/plugin.jar — the shape the `plugin-velocity-jar` Helm chart expects +# (oci://ghcr.io/groundsgg/charts/plugin-velocity-jar). Velocity proxy pods +# in a per-engineer vCluster fetch this image's JAR via the chart's +# init-container + httpd indirection. +# +# Pushed as `ghcr.io/groundsgg/plugin-player:edge` (main) / `:` (tag) +# by .github/workflows/docker-gradle-build-push.yml. + +FROM eclipse-temurin:25-jdk AS build +WORKDIR /src + +# GitHub Packages credentials for the gg.grounds.velocity convention plugin. +# The token comes from the `github_token` build secret (never a build-arg — +# that would leak it into the layer history). +ARG GITHUB_USER + +# Copy gradle wrapper + root config first so dependency caches stay warm +# across source-only changes. +COPY gradle/ gradle/ +COPY gradlew settings.gradle.kts build.gradle.kts ./ + +COPY common/ common/ +COPY velocity/ velocity/ + +# `:velocity:build` produces the shaded plugin JAR. plugin-proxy-api stays +# compileOnly (never shaded): the ProxyServiceRegistry this plugin writes its +# session lookup into must be the class plugin-proxy loaded at runtime. +RUN --mount=type=secret,id=github_token,required=true \ + /bin/sh -euc '\ + : "${GITHUB_USER:?GITHUB_USER build arg is required}"; \ + token="$(cat /run/secrets/github_token)"; \ + ./gradlew --no-daemon :velocity:build \ + -Pgithub.user="${GITHUB_USER}" \ + -Pgithub.token="${token}" \ + ' + +RUN mkdir -p /out && \ + cp "$(ls -S /src/velocity/build/libs/*.jar | head -n1)" /out/plugin.jar + +FROM alpine:3 +RUN mkdir -p /jar +COPY --from=build /out/plugin.jar /jar/plugin.jar +# No ENTRYPOINT — the plugin-velocity-jar chart's init-container `cp`s +# /jar/plugin.jar out, then the chart's main container (busybox httpd) +# serves the JAR. This image only carries data. From 223f2e7cd1b4387eb28b89d16c054d71e88bc260 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Sun, 12 Jul 2026 21:22:49 +0200 Subject: [PATCH 3/3] fix(presence): authenticate against service-player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit service-player runs with `grounds.auth.enabled=true` and rejects calls without a Bearer token with UNAUTHENTICATED — and every failure in this client is swallowed into "unknown". Logins would have looked like a flaky presence service, and the whole cross-proxy lookup chain would have failed silently. Attach the projected ServiceAccount JWT (audience grounds-services) the charts already mount into the proxy pod, re-read per call for kubelet rotation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01E92vTegZqXd2HqjE7jnDJz --- common/build.gradle.kts | 8 +- .../presence/GroundsTokenInterceptor.kt | 57 +++++++++++++ .../presence/GrpcPlayerPresenceClient.kt | 4 + .../presence/GroundsTokenInterceptorTest.kt | 80 +++++++++++++++++++ 4 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 common/src/main/kotlin/gg/grounds/player/presence/GroundsTokenInterceptor.kt create mode 100644 common/src/test/kotlin/gg/grounds/player/presence/GroundsTokenInterceptorTest.kt diff --git a/common/build.gradle.kts b/common/build.gradle.kts index c157fe9..818d935 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -10,4 +10,10 @@ repositories { } } -dependencies { protobuf("gg.grounds:library-grpc-contracts-player:0.3.0") } +dependencies { + protobuf("gg.grounds:library-grpc-contracts-player:0.3.0") + + testImplementation("org.junit.jupiter:junit-jupiter-api:5.13.4") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.13.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.13.4") +} diff --git a/common/src/main/kotlin/gg/grounds/player/presence/GroundsTokenInterceptor.kt b/common/src/main/kotlin/gg/grounds/player/presence/GroundsTokenInterceptor.kt new file mode 100644 index 0000000..26fb8a5 --- /dev/null +++ b/common/src/main/kotlin/gg/grounds/player/presence/GroundsTokenInterceptor.kt @@ -0,0 +1,57 @@ +package gg.grounds.player.presence + +import io.grpc.CallOptions +import io.grpc.Channel +import io.grpc.ClientCall +import io.grpc.ClientInterceptor +import io.grpc.ForwardingClientCall +import io.grpc.Metadata +import io.grpc.MethodDescriptor +import java.nio.file.Files +import java.nio.file.Path + +/** + * Attaches the projected ServiceAccount JWT as `Authorization: Bearer ...` to every outgoing call. + * + * service-player runs with `grounds.auth.enabled=true` and rejects tokenless calls with + * UNAUTHENTICATED. The Grounds charts project a short-lived token (audience `grounds-services`) + * into the proxy pod and point [TOKEN_FILE_ENV] at it; kubelet rotates the file, so it is re-read + * per call rather than cached. + * + * With no token file present (local dev against a service running `grounds.auth.enabled=false`) the + * call goes out without the header. + */ +class GroundsTokenInterceptor(private val tokenLoader: () -> String? = ::loadTokenFromFile) : + ClientInterceptor { + + override fun interceptCall( + method: MethodDescriptor, + callOptions: CallOptions, + next: Channel, + ): ClientCall { + val delegate = next.newCall(method, callOptions) + return object : ForwardingClientCall.SimpleForwardingClientCall(delegate) { + override fun start(responseListener: Listener, headers: Metadata) { + tokenLoader()?.let { headers.put(AUTHORIZATION, "Bearer $it") } + super.start(responseListener, headers) + } + } + } + + companion object { + const val TOKEN_FILE_ENV = "GROUNDS_TOKEN_FILE" + const val DEFAULT_TOKEN_PATH = "/var/run/secrets/grounds/token" + + private val AUTHORIZATION: Metadata.Key = + Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER) + + private fun loadTokenFromFile(): String? { + val path = Path.of(System.getenv(TOKEN_FILE_ENV) ?: DEFAULT_TOKEN_PATH) + return try { + if (Files.exists(path)) Files.readString(path).trim().ifEmpty { null } else null + } catch (_: Exception) { + null + } + } + } +} diff --git a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt b/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt index 35e7c71..0a47337 100644 --- a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt +++ b/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt @@ -157,6 +157,10 @@ private constructor( fun create(target: String): GrpcPlayerPresenceClient { val channelBuilder = ManagedChannelBuilder.forTarget(target) channelBuilder.usePlaintext() + // service-player rejects tokenless calls with UNAUTHENTICATED, and every failure here + // is + // swallowed into "unknown" — so without this the whole presence chain fails silently. + channelBuilder.intercept(GroundsTokenInterceptor()) val channel = channelBuilder.build() val stub = PlayerPresenceServiceGrpc.newBlockingStub(channel) return GrpcPlayerPresenceClient(channel, stub) diff --git a/common/src/test/kotlin/gg/grounds/player/presence/GroundsTokenInterceptorTest.kt b/common/src/test/kotlin/gg/grounds/player/presence/GroundsTokenInterceptorTest.kt new file mode 100644 index 0000000..855bfe2 --- /dev/null +++ b/common/src/test/kotlin/gg/grounds/player/presence/GroundsTokenInterceptorTest.kt @@ -0,0 +1,80 @@ +package gg.grounds.player.presence + +import io.grpc.CallOptions +import io.grpc.Channel +import io.grpc.ClientCall +import io.grpc.Metadata +import io.grpc.MethodDescriptor +import java.io.InputStream +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test + +class GroundsTokenInterceptorTest { + + @Test + fun attachesBearerTokenWhenTokenIsAvailable() { + val channel = CapturingChannel() + + GroundsTokenInterceptor(tokenLoader = { "jwt-abc" }) + .interceptCall(METHOD, CallOptions.DEFAULT, channel) + .start(NoopListener(), Metadata()) + + assertEquals("Bearer jwt-abc", channel.capturedHeaders?.get(AUTHORIZATION)) + } + + @Test + fun sendsNoAuthorizationHeaderWhenTokenIsMissing() { + val channel = CapturingChannel() + + GroundsTokenInterceptor(tokenLoader = { null }) + .interceptCall(METHOD, CallOptions.DEFAULT, channel) + .start(NoopListener(), Metadata()) + + assertFalse(channel.capturedHeaders?.containsKey(AUTHORIZATION) ?: true) + } + + private class CapturingChannel : Channel() { + var capturedHeaders: Metadata? = null + + override fun authority(): String = "test" + + override fun newCall( + methodDescriptor: MethodDescriptor, + callOptions: CallOptions, + ): ClientCall = + object : ClientCall() { + override fun start(responseListener: Listener, headers: Metadata) { + capturedHeaders = headers + } + + override fun request(numMessages: Int) = Unit + + override fun cancel(message: String?, cause: Throwable?) = Unit + + override fun halfClose() = Unit + + override fun sendMessage(message: ReqT) = Unit + } + } + + private class NoopListener : ClientCall.Listener() + + private companion object { + val AUTHORIZATION: Metadata.Key = + Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER) + + val MARSHALLER = + object : MethodDescriptor.Marshaller { + override fun stream(value: String): InputStream = value.byteInputStream() + + override fun parse(stream: InputStream): String = stream.reader().readText() + } + + val METHOD: MethodDescriptor = + MethodDescriptor.newBuilder(MARSHALLER, MARSHALLER) + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("gg.grounds.player.Test/Call") + .build() + } +}