diff --git a/docs/superpowers/plans/2026-10-19-network-maintenance.md b/docs/superpowers/plans/2026-10-19-network-maintenance.md
new file mode 100644
index 0000000..c044018
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-19-network-maintenance.md
@@ -0,0 +1,8 @@
+# Implementation plan
+
+1. Config Service: multi-document least-privilege grants; atomic create authorization; tests and verification.
+2. Proxy: pure validated schedule engine and bypass admission/kick tests; Config polling; LoginEvent and server-list integration; permission manifest and localization; build.
+3. Forge: strict typed contract; bounded private Config client/CAS and atomic create; environment-specific RBAC routes; tests/typecheck.
+4. Portal: protected environment-aware network page; schedule/whitelist/announcements editor, explicit bypass kick behavior, MOTD editor and safe preview; tests/typecheck/build.
+5. Infra: exact grants and NATS publisher subjects, explicit environment connections; relevant checks.
+6. Strong primary security/correctness review of all domains, cross-proxy behavior, conflicts, unavailable Production, UUID handling and bypass kicks. Create reviewable PRs and record deployment prerequisites. Never turn maintenance on to test.
diff --git a/docs/superpowers/specs/2026-10-19-network-maintenance-design.md b/docs/superpowers/specs/2026-10-19-network-maintenance-design.md
new file mode 100644
index 0000000..19755cd
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-19-network-maintenance-design.md
@@ -0,0 +1,41 @@
+# Network maintenance and MOTD
+
+Approved scope: Config Service backed maintenance, scheduled start, optional automatic end, separately estimated end, permission/UUID whitelist bypass, kick existing non-bypass players, configurable announcements, all proxies/Java/Bedrock, Portal maintenance and MOTD editors. Never enable live maintenance as a deployment test.
+
+## Contract
+
+Config app `velocity`, environment `stage` or `prod`, documents `maintenance/active` and existing `motd/active`. Stage and Production are separate connections, never fall back across environments.
+
+Maintenance JSON (strict at Forge, compatible explicit parser in proxy):
+```
+{
+ "schemaVersion": 1,
+ "enabled": false,
+ "id": "UUID",
+ "startsAt": "2026-10-19T20:00:00.000Z",
+ "endsAt": null,
+ "estimatedEndAt": null,
+ "reason": "Wartungsarbeiten",
+ "joinMessage": "Wartungsarbeiten\n{reason}\nVoraussichtliches Ende: {end}",
+ "motd": "Wartungsarbeiten\nVoraussichtliches Ende: {end}",
+ "kickExisting": true,
+ "whitelist": [],
+ "announcementSeconds": [10800,7200,3600,1800,600,300,120,60],
+ "announcementMessage": "Wartungsarbeiten in {remaining}: {reason}"
+}
+```
+Required fields above. UUID canonical, no UUID-version constraint (Floodgate support). Dates ISO UTC. endsAt/estimatedEndAt nullable, if set > startsAt. endsAt automatically disables effective maintenance; estimatedEndAt only displays ETA. Limits: reason 500 chars, messages/MOTD 4096 chars, whitelist 1000 UUIDs, offsets <=32 distinct integers 1..604800. Preserve ID on edits; create a new ID when intentionally starting a new episode. Offset threshold local announcements never replay after bootstrap/restart. Whitelist OR `grounds.maintenance.bypass` exempts joins AND existing-player kicks. Kick only on transition/episode entry, never merely because a config refresh changed metadata. Local player enumeration and delivery on every proxy ensures network-wide behavior without duplicate cross-proxy broadcasts.
+
+MOTD JSON preserves existing shape: {text:string,maxPlayers?:integer,source?:string,updatedBy?:string,updatedAt?:string}. text 1..4096, maxPlayers 1..1000000. Forge stamps actor/time. Preview is safely rendered React text, supported-format preview may be partial and must say so.
+
+Forge routes:
+- GET /v1/admin/network/:environment => {environment,available,maintenance:Document|null,motd:Document|null}. Config missing document returns defaults in Document.settings and version:null. Unconfigured environment => available:false, both null.
+- PUT /v1/admin/network/:environment/maintenance or /motd body {expectedVersion:string|null,settings:...} => Document.
+- Document = {version:string|null,settings:...,updatedAt:string|null,updatedBy:string|null}.
+- Decimal version strings preserve BIGINT precision. null means atomic create-if-absent using Config POST; existing updates require If-Match. Conflict 409. No unconditional writes or arbitrary config paths.
+- Permissions ADMIN_AREA_ACCESS plus NETWORK_STAGE_VIEW/NETWORK_STAGE_MANAGE or NETWORK_PRODUCTION_VIEW/NETWORK_PRODUCTION_MANAGE. Manage implies ability to view via either-permission check. Service-account Forge JWTs cannot enter these routes.
+- Forge dedicated env NETWORK_CONFIG_STAGE_URL/TOKEN_FILE and NETWORK_CONFIG_PRODUCTION_URL/TOKEN_FILE. Re-read projected token per request. Fixed paths, 5s timeout, no redirects, bounded responses, sanitized errors.
+
+Proxy caches valid documents, polls every 5s and refreshes on NATS invalidation hints. Pings/logins never do remote IO. Keep last valid on failure, deny ordinary new joins once read freshness >30s; bypass still allowed. No mass kick caused by config outages. HTTP 404 is known disabled initial configuration. LoginEvent uses verified UUID and permission provider. Maintenance ping overrides normal MOTD; Java may advertise Maintenance status with incompatible protocol while actual LoginEvent enforces admission. Bedrock sees MOTD and login denial.
+
+Infrastructure: Forge SA receives exactly existing network/stage/resourcepacks/global plus velocity/stage/{motd,maintenance}/active via pipe-separated grants. Config create endpoint accepts exact writers consistent with PUT. NATS Stage Config publisher adds config.velocity.stage.changed to existing subject. Preserve live bindings/flags and never change unrelated networking enforcement.
diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt
index 7768efb..882d90b 100644
--- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt
+++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt
@@ -2,7 +2,10 @@ package gg.grounds.proxy.velocity
import com.google.gson.JsonParser
import com.google.inject.Inject
+import com.velocitypowered.api.event.PostOrder
+import com.velocitypowered.api.event.ResultedEvent
import com.velocitypowered.api.event.Subscribe
+import com.velocitypowered.api.event.connection.LoginEvent
import com.velocitypowered.api.event.connection.PostLoginEvent
import com.velocitypowered.api.event.player.ServerPostConnectEvent
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent
@@ -12,6 +15,7 @@ import com.velocitypowered.api.plugin.Plugin
import com.velocitypowered.api.proxy.Player
import com.velocitypowered.api.proxy.ProxyServer
import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier
+import com.velocitypowered.api.scheduler.ScheduledTask
import gg.grounds.BuildInfo
import gg.grounds.i18n.Translations
import gg.grounds.presentation.VelocitySystemMessages
@@ -31,6 +35,9 @@ import gg.grounds.proxy.velocity.command.RegionCommand
import gg.grounds.proxy.velocity.handler.NatsHandler
import gg.grounds.proxy.velocity.listener.LobbyCountsListener
import gg.grounds.proxy.velocity.listener.ProxyHopListener
+import gg.grounds.proxy.velocity.maintenance.MaintenanceAccess
+import gg.grounds.proxy.velocity.maintenance.MaintenanceConfigStore
+import gg.grounds.proxy.velocity.maintenance.MaintenanceEngine
import gg.grounds.proxy.velocity.metrics.MetricsConfig
import gg.grounds.proxy.velocity.metrics.ProxyMetrics
import gg.grounds.proxy.velocity.metrics.ProxySnapshot
@@ -44,6 +51,8 @@ import gg.grounds.proxy.velocity.tab.TabList
import io.nats.client.Subscription
import java.time.Duration
import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import net.kyori.adventure.text.minimessage.MiniMessage
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer
import org.slf4j.Logger
@@ -207,6 +216,12 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) {
*/
@Volatile private var motdManager: MotdManager? = null
private var motdStore: MotdConfigStore? = null
+ @Volatile private var maintenanceStore: MaintenanceConfigStore? = null
+ private val maintenance = MaintenanceEngine()
+ private val maintenanceRefreshRunning = AtomicBoolean()
+ private val maintenanceClosed = AtomicBoolean()
+ private var maintenanceRefreshTask: ScheduledTask? = null
+ private var maintenanceApplyTask: ScheduledTask? = null
@Subscribe
fun onInitialize(event: ProxyInitializeEvent) {
@@ -278,6 +293,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) {
}
startMotd()
+ startMaintenance()
subscribeForProxy()
@@ -417,6 +433,78 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) {
)
}
+ /** Starts the shared maintenance reader. Every proxy polls and only acts on its own players. */
+ private fun startMaintenance() {
+ val target = resolveConfigServiceTarget() ?: return
+ val app = env("CONFIG_APP") ?: DEFAULT_CONFIG_APP
+ val configEnv = env("CONFIG_ENV") ?: env("GROUNDS_PERMISSION_ENVIRONMENT") ?: return
+ maintenanceStore = MaintenanceConfigStore.open(app, configEnv, target)
+ // Hints shorten propagation after Portal saves; polling remains the correctness fallback.
+ crossProxySubscriptions +=
+ natsHandler.subscribe("config.$app.$configEnv.changed") {
+ if (!maintenanceClosed.get()) {
+ proxy.scheduler
+ .buildTask(this, Runnable { refreshMaintenanceConfiguration() })
+ .schedule()
+ }
+ }
+ maintenanceRefreshTask =
+ proxy.scheduler
+ .buildTask(this, Runnable { refreshMaintenanceConfiguration() })
+ .repeat(5, TimeUnit.SECONDS)
+ .schedule()
+ // The local schedule still advances while Config is unavailable: end times and countdowns
+ // are decisions from the cached signed document, not from a successful request this tick.
+ maintenanceApplyTask =
+ proxy.scheduler
+ .buildTask(this, Runnable { applyMaintenance() })
+ .repeat(1, TimeUnit.SECONDS)
+ .schedule()
+ }
+
+ private fun refreshMaintenanceConfiguration() {
+ if (maintenanceClosed.get()) return
+ val store = maintenanceStore ?: return
+ if (!maintenanceRefreshRunning.compareAndSet(false, true)) return
+ try {
+ val document = store.read()
+ if (!maintenanceClosed.get()) maintenance.adopt(document)
+ } catch (failure: Exception) {
+ maintenance.refreshFailed()
+ logger.warn(
+ "Maintenance refresh failed (reason={}); retaining last valid state",
+ failure.message,
+ )
+ } finally {
+ maintenanceRefreshRunning.set(false)
+ }
+ }
+
+ private fun applyMaintenance() {
+ if (maintenanceClosed.get()) return
+ val now = java.time.Instant.now()
+ maintenance.announcementsAt(now).forEach {
+ maintenance.announcementMessage(now)?.let { message ->
+ val component = MiniMessage.miniMessage().deserialize(message)
+ proxy.allPlayers.forEach { player -> player.sendMessage(component) }
+ }
+ }
+ proxy.allPlayers.forEach { player ->
+ if (
+ MaintenanceAccess.shouldKick(
+ maintenance,
+ player.uniqueId,
+ { player.hasPermission(MaintenanceAccess.BYPASS_PERMISSION) },
+ now,
+ )
+ ) {
+ player.disconnect(
+ MiniMessage.miniMessage().deserialize(maintenance.denialMessage(now))
+ )
+ }
+ }
+ }
+
private fun env(name: String): String? = System.getenv(name)?.trim()?.takeIf { it.isNotEmpty() }
/**
@@ -438,7 +526,8 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) {
fun onProxyPing(event: ProxyPingEvent) {
val count = networkPlayerCount
val motd = motdManager
- if (count == null && motd == null) return
+ val maintenanceMotd = maintenanceStore?.let { maintenance.motd() }
+ if (count == null && motd == null && maintenanceMotd == null) return
val builder = event.ping.asBuilder()
count?.let { builder.onlinePlayers(it) }
@@ -450,9 +539,30 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) {
builder.description(it)
}
}
+ maintenanceMotd?.let { builder.description(MiniMessage.miniMessage().deserialize(it)) }
event.ping = builder.build()
}
+ @Subscribe(order = PostOrder.LAST)
+ fun onLogin(event: LoginEvent) {
+ if (!event.result.isAllowed) return
+ if (maintenanceStore == null) return
+ val player = event.player
+ val admission =
+ MaintenanceAccess.admission(
+ maintenance,
+ player.uniqueId,
+ { player.hasPermission(MaintenanceAccess.BYPASS_PERMISSION) },
+ java.time.Instant.now(),
+ )
+ if (!admission.allowed) {
+ event.result =
+ ResultedEvent.ComponentResult.denied(
+ MiniMessage.miniMessage().deserialize(admission.message!!)
+ )
+ }
+ }
+
@Subscribe
fun onLogin(event: PostLoginEvent) {
bedrockRoster?.refresh()
@@ -477,6 +587,11 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) {
crossProxySubscriptions.forEach { natsHandler.unsubscribe(it) }
crossProxySubscriptions.clear()
motdStore?.close()
+ maintenanceClosed.set(true)
+ maintenanceRefreshTask?.cancel()
+ maintenanceApplyTask?.cancel()
+ maintenanceStore?.close()
+ maintenanceStore = null
metrics?.let { runCatching { it.close() } }
metrics = null
if (this::natsHandler.isInitialized) {
diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceAccess.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceAccess.kt
new file mode 100644
index 0000000..f883a80
--- /dev/null
+++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceAccess.kt
@@ -0,0 +1,33 @@
+package gg.grounds.proxy.velocity.maintenance
+
+import java.time.Instant
+import java.util.UUID
+
+/**
+ * Keeps Velocity permission lookup at the listener boundary and makes whitelist precedence
+ * explicit.
+ */
+object MaintenanceAccess {
+ const val BYPASS_PERMISSION = "grounds.maintenance.bypass"
+
+ fun admission(
+ engine: MaintenanceEngine,
+ uuid: UUID,
+ hasPermission: () -> Boolean,
+ now: Instant,
+ ): MaintenanceEngine.Admission =
+ engine.admission(uuid, bypass(engine, uuid, hasPermission), now)
+
+ fun shouldKick(
+ engine: MaintenanceEngine,
+ uuid: UUID,
+ hasPermission: () -> Boolean,
+ now: Instant,
+ ): Boolean = engine.shouldKick(uuid, bypass(engine, uuid, hasPermission), now)
+
+ private fun bypass(
+ engine: MaintenanceEngine,
+ uuid: UUID,
+ hasPermission: () -> Boolean,
+ ): Boolean = engine.whitelisted(uuid) || runCatching(hasPermission).getOrDefault(false)
+}
diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStore.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStore.kt
new file mode 100644
index 0000000..a91aa58
--- /dev/null
+++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStore.kt
@@ -0,0 +1,266 @@
+package gg.grounds.proxy.velocity.maintenance
+
+import com.google.gson.JsonParser
+import java.io.ByteArrayOutputStream
+import java.net.URI
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+import java.nio.ByteBuffer
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.nio.file.Path
+import java.time.Duration
+import java.time.Instant
+import java.util.UUID
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.CompletionStage
+import java.util.concurrent.Flow
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+
+/** Read-only service-config client for the shared maintenance document. */
+class MaintenanceConfigStore
+internal constructor(
+ private val app: String,
+ private val env: String,
+ private val baseUri: URI,
+ private val http: HttpClient,
+ private val deadline: Duration = DEADLINE,
+) : AutoCloseable {
+ fun read(): MaintenanceDocument? {
+ val request =
+ HttpRequest.newBuilder(baseUri.resolve(path()))
+ .timeout(deadline)
+ .header("Accept", "application/json")
+ token()?.let { request.header("Authorization", "Bearer $it") }
+ val subscriber = BoundedBodySubscriber(MAX_RESPONSE_BYTES)
+ val pending = http.sendAsync(request.GET().build()) { subscriber }
+ val response =
+ try {
+ // JDK 25 request timeouts finish at headers. Bound the entire body exchange as
+ // well.
+ pending.get(deadline.toMillis(), TimeUnit.MILLISECONDS)
+ } catch (_: TimeoutException) {
+ subscriber.cancel()
+ pending.cancel(true)
+ throw MaintenanceStoreException("Maintenance request timed out")
+ } catch (_: InterruptedException) {
+ subscriber.cancel()
+ pending.cancel(true)
+ Thread.currentThread().interrupt()
+ throw MaintenanceStoreException("Maintenance request interrupted")
+ } catch (_: Exception) {
+ subscriber.cancel()
+ pending.cancel(true)
+ throw MaintenanceStoreException("Maintenance response could not be read")
+ }
+ val body = response.body()
+ if (response.statusCode() == 404) return null
+ if (response.statusCode() !in 200..299)
+ throw MaintenanceStoreException(
+ "Could not read maintenance (HTTP ${response.statusCode()})"
+ )
+ val content =
+ JsonParser.parseString(body).asJsonObject.get("contentJson")?.asString
+ ?: throw MaintenanceStoreException("Maintenance response has no contentJson")
+ return parse(content)
+ }
+
+ override fun close() = http.close()
+
+ private fun path() = "/v1/config/apps/$app/envs/$env/namespaces/maintenance/documents/active"
+
+ companion object {
+ private val DEADLINE = Duration.ofSeconds(5)
+ private const val MAX_RESPONSE_BYTES = 262_144
+ private val UUID_PATTERN =
+ Regex("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
+
+ private fun canonicalUuid(raw: String): UUID {
+ if (!UUID_PATTERN.matches(raw))
+ throw MaintenanceStoreException("Maintenance UUID is not canonical")
+ return UUID.fromString(raw)
+ }
+
+ fun open(app: String, env: String, target: String): MaintenanceConfigStore {
+ val uri = URI.create(if (target.contains("://")) target else "http://$target")
+ return MaintenanceConfigStore(app, env, uri, HttpClient.newHttpClient())
+ }
+
+ internal fun parse(json: String): MaintenanceDocument {
+ val root =
+ runCatching { JsonParser.parseString(json).asJsonObject }
+ .getOrElse {
+ throw MaintenanceStoreException("Maintenance document is not an object")
+ }
+ fun string(name: String): String =
+ root
+ .get(name)
+ ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }
+ ?.asString
+ ?: throw MaintenanceStoreException("Maintenance field '$name' is required")
+ fun bool(name: String): Boolean =
+ root
+ .get(name)
+ ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isBoolean }
+ ?.asBoolean
+ ?: throw MaintenanceStoreException("Maintenance field '$name' is required")
+ fun instant(name: String, nullable: Boolean = false): Instant? {
+ val value =
+ root.get(name)
+ ?: throw MaintenanceStoreException("Maintenance field '$name' is required")
+ if (nullable && value.isJsonNull) return null
+ return runCatching { Instant.parse(value.asString) }
+ .getOrElse {
+ throw MaintenanceStoreException("Maintenance field '$name' is not ISO UTC")
+ }
+ }
+ val schema = root.get("schemaVersion")
+ if (
+ schema == null ||
+ !schema.isJsonPrimitive ||
+ !schema.asJsonPrimitive.isNumber ||
+ schema.asString != "1"
+ )
+ throw MaintenanceStoreException("Unsupported maintenance schema")
+ val idText = string("id")
+ val id = canonicalUuid(idText)
+ val startsAt = instant("startsAt")!!
+ val endsAt = instant("endsAt", true)
+ val estimatedEndAt = instant("estimatedEndAt", true)
+ if (
+ (endsAt != null && !endsAt.isAfter(startsAt)) ||
+ (estimatedEndAt != null && !estimatedEndAt.isAfter(startsAt))
+ )
+ throw MaintenanceStoreException("Maintenance end must be after start")
+ val reason =
+ string("reason").also {
+ if (it.isEmpty() || it.length > 500)
+ throw MaintenanceStoreException("Maintenance reason is invalid")
+ }
+ val join =
+ string("joinMessage").also {
+ if (it.isEmpty() || it.length > 4096)
+ throw MaintenanceStoreException("Maintenance joinMessage is invalid")
+ }
+ val motd =
+ string("motd").also {
+ if (it.isEmpty() || it.length > 4096)
+ throw MaintenanceStoreException("Maintenance motd is invalid")
+ }
+ val announcement =
+ string("announcementMessage").also {
+ if (it.isEmpty() || it.length > 4096)
+ throw MaintenanceStoreException(
+ "Maintenance announcementMessage is invalid"
+ )
+ }
+ val whitelist =
+ root.getAsJsonArray("whitelist")
+ ?: throw MaintenanceStoreException("Maintenance field 'whitelist' is required")
+ if (whitelist.size() > 1000)
+ throw MaintenanceStoreException("Maintenance whitelist is too long")
+ val uuids =
+ whitelist.map { entry ->
+ if (!entry.isJsonPrimitive || !entry.asJsonPrimitive.isString)
+ throw MaintenanceStoreException("Whitelist entries must be UUID strings")
+ canonicalUuid(entry.asString)
+ }
+ if (uuids.distinct().size != uuids.size)
+ throw MaintenanceStoreException("Maintenance whitelist has duplicate UUIDs")
+ val offsets =
+ root.getAsJsonArray("announcementSeconds")
+ ?: throw MaintenanceStoreException(
+ "Maintenance field 'announcementSeconds' is required"
+ )
+ val seconds =
+ offsets.map { entry ->
+ if (
+ !entry.isJsonPrimitive ||
+ !entry.asJsonPrimitive.isNumber ||
+ !entry.asString.matches(Regex("[1-9][0-9]*"))
+ )
+ throw MaintenanceStoreException("Invalid announcement seconds")
+ entry.asString.toLongOrNull()
+ ?: throw MaintenanceStoreException("Invalid announcement seconds")
+ }
+ if (
+ seconds.size > 32 ||
+ seconds.distinct().size != seconds.size ||
+ seconds.any { it !in 1..604800 }
+ )
+ throw MaintenanceStoreException("Invalid announcement seconds")
+ return MaintenanceDocument(
+ bool("enabled"),
+ id,
+ startsAt,
+ endsAt,
+ estimatedEndAt,
+ reason,
+ join,
+ motd,
+ bool("kickExisting"),
+ uuids.toSet(),
+ seconds,
+ announcement,
+ )
+ }
+
+ private fun token(): String? =
+ runCatching {
+ val file =
+ Path.of(
+ System.getenv("GROUNDS_TOKEN_FILE") ?: "/var/run/secrets/grounds/token"
+ )
+ if (Files.exists(file)) Files.readString(file).trim().ifEmpty { null } else null
+ }
+ .getOrNull()
+ }
+}
+
+class MaintenanceStoreException(message: String) : RuntimeException(message)
+
+/** Bounds memory during streaming, before the response future is allowed to complete. */
+private class BoundedBodySubscriber(private val limit: Int) : HttpResponse.BodySubscriber {
+ private val result = CompletableFuture()
+ private val bytes = ByteArrayOutputStream()
+ @Volatile private var subscription: Flow.Subscription? = null
+
+ override fun getBody(): CompletionStage = result
+
+ override fun onSubscribe(next: Flow.Subscription) {
+ subscription = next
+ if (result.isDone) next.cancel() else next.request(1)
+ }
+
+ override fun onNext(items: List) {
+ if (result.isDone) return
+ for (buffer in items) {
+ if (buffer.remaining() > limit - bytes.size()) {
+ subscription?.cancel()
+ result.completeExceptionally(
+ MaintenanceStoreException("Maintenance response is too large")
+ )
+ return
+ }
+ val chunk = ByteArray(buffer.remaining())
+ buffer.get(chunk)
+ bytes.write(chunk)
+ }
+ subscription?.request(1)
+ }
+
+ override fun onError(failure: Throwable) {
+ result.completeExceptionally(failure)
+ }
+
+ override fun onComplete() {
+ result.complete(bytes.toString(StandardCharsets.UTF_8))
+ }
+
+ fun cancel() {
+ result.cancel(true)
+ subscription?.cancel()
+ }
+}
diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngine.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngine.kt
new file mode 100644
index 0000000..54ff1dc
--- /dev/null
+++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngine.kt
@@ -0,0 +1,163 @@
+package gg.grounds.proxy.velocity.maintenance
+
+import java.time.Clock
+import java.time.Instant
+import java.util.UUID
+import net.kyori.adventure.text.minimessage.MiniMessage
+
+/**
+ * Local, side-effect-free maintenance state. Each proxy owns one instance: the shared document is
+ * only the decision input, while kicking and messaging intentionally remain local operations.
+ */
+class MaintenanceEngine(private val clock: Clock = Clock.systemUTC()) {
+ data class Admission(val allowed: Boolean, val message: String? = null)
+
+ private var document: MaintenanceDocument? = null
+ private var lastSuccess: Instant? = null
+ private val kicked = mutableSetOf()
+ private val announced = mutableSetOf()
+ private var lastRemaining: Long? = null
+
+ @Synchronized
+ fun adopt(next: MaintenanceDocument?, receivedAt: Instant = clock.instant()) {
+ val old = document
+ val oldId = old?.id
+ document = next
+ lastSuccess = receivedAt
+ if (next?.id != oldId) {
+ kicked.clear()
+ announced.clear()
+ // A fresh proxy must never replay old countdown notices on bootstrap.
+ lastRemaining = next?.startsAt?.let { it.epochSecond - receivedAt.epochSecond }
+ } else if (
+ next != null &&
+ (next.startsAt != old?.startsAt || (old?.enabled == false && next.enabled))
+ ) {
+ // Rescheduling an existing episode preserves delivered notices and kick history, while
+ // establishing a new countdown baseline so earlier offsets do not replay at once.
+ lastRemaining = next.startsAt.epochSecond - receivedAt.epochSecond
+ }
+ }
+
+ /** Records a failed or malformed fetch while retaining the last known valid snapshot. */
+ @Synchronized
+ fun refreshFailed(at: Instant = clock.instant()) {
+ // The timestamp is intentionally not changed. Freshness is time since a valid read.
+ }
+
+ @Synchronized
+ fun activeAt(now: Instant = clock.instant()): Boolean =
+ document?.let {
+ it.enabled &&
+ !now.isBefore(it.startsAt) &&
+ (it.endsAt == null || now.isBefore(it.endsAt))
+ } ?: false
+
+ @Synchronized fun whitelisted(uuid: UUID): Boolean = document?.whitelist?.contains(uuid) == true
+
+ @Synchronized
+ fun admission(uuid: UUID, bypass: Boolean, now: Instant = clock.instant()): Admission {
+ if (bypass || document?.whitelist?.contains(uuid) == true) return Admission(true)
+ // A recorded end is a local schedule too: once it has elapsed, maintenance ends even if
+ // service-config is temporarily unavailable.
+ if (document?.endsAt?.let { !now.isBefore(it) } == true) return Admission(true)
+ if (unhealthy(now))
+ return Admission(false, "Maintenance configuration is temporarily unavailable.")
+ if (!activeAt(now)) return Admission(true)
+ return Admission(false, denialMessage(now))
+ }
+
+ /** Returns true once per local player per maintenance episode. */
+ @Synchronized
+ fun shouldKick(uuid: UUID, bypass: Boolean, now: Instant = clock.instant()): Boolean {
+ val current = document ?: return false
+ if (!current.kickExisting || bypass || uuid in current.whitelist || !activeAt(now))
+ return false
+ return kicked.add(uuid)
+ }
+
+ /** Announcements whose threshold was crossed since the previous local scheduler tick. */
+ @Synchronized
+ fun announcementsAt(now: Instant = clock.instant()): List {
+ val current = document ?: return emptyList()
+ if (!current.enabled || !now.isBefore(current.startsAt)) return emptyList()
+ val remaining = current.startsAt.epochSecond - now.epochSecond
+ val previous = lastRemaining.also { lastRemaining = remaining } ?: return emptyList()
+ val crossed =
+ current.announcementSeconds.filter {
+ it < previous && it >= remaining && announced.add(it)
+ }
+ return crossed.minOrNull()?.let(::listOf) ?: emptyList()
+ }
+
+ @Synchronized
+ fun motd(now: Instant = clock.instant()): String? =
+ document
+ ?.takeIf {
+ it.enabled &&
+ !now.isBefore(it.startsAt) &&
+ (it.endsAt == null || now.isBefore(it.endsAt))
+ }
+ ?.let { render(it.motd, now) }
+
+ @Synchronized
+ fun announcementMessage(now: Instant = clock.instant()): String? =
+ document
+ ?.takeIf { it.enabled && now.isBefore(it.startsAt) }
+ ?.let { render(it.announcementMessage, now) }
+
+ private fun unhealthy(now: Instant): Boolean =
+ lastSuccess?.let { now.epochSecond - it.epochSecond > STALE_AFTER_SECONDS } ?: true
+
+ @Synchronized
+ fun denialMessage(now: Instant = clock.instant()): String =
+ document?.let { render(it.joinMessage, now) }.orEmpty()
+
+ private fun render(template: String, now: Instant): String {
+ val end = document?.endsAt ?: document?.estimatedEndAt
+ val values =
+ mapOf(
+ "reason" to escape(document?.reason.orEmpty()),
+ "end" to escape(end?.toString() ?: "to be announced"),
+ "remaining" to
+ formatRemaining(
+ (document?.startsAt?.epochSecond ?: now.epochSecond) - now.epochSecond
+ ),
+ )
+ return PLACEHOLDER.replace(template) { values.getValue(it.groupValues[1]) }
+ }
+
+ private fun escape(value: String): String = MiniMessage.miniMessage().escapeTags(value)
+
+ private fun formatRemaining(seconds: Long): String {
+ val safe = seconds.coerceAtLeast(0)
+ val hours = safe / 3600
+ val minutes = (safe % 3600) / 60
+ val rest = safe % 60
+ return when {
+ hours > 0 -> "${hours}h ${minutes}m"
+ minutes > 0 -> "${minutes}m"
+ else -> "${rest}s"
+ }
+ }
+
+ companion object {
+ const val STALE_AFTER_SECONDS = 30L
+ private val PLACEHOLDER = Regex("\\{(reason|end|remaining)}")
+ }
+}
+
+data class MaintenanceDocument(
+ val enabled: Boolean,
+ val id: UUID,
+ val startsAt: Instant,
+ val endsAt: Instant?,
+ val estimatedEndAt: Instant?,
+ val reason: String,
+ val joinMessage: String,
+ val motd: String,
+ val kickExisting: Boolean,
+ val whitelist: Set,
+ val announcementSeconds: List,
+ val announcementMessage: String,
+)
diff --git a/velocity/src/main/resources/META-INF/grounds/permissions.json b/velocity/src/main/resources/META-INF/grounds/permissions.json
index 3d2c3e6..d28e710 100644
--- a/velocity/src/main/resources/META-INF/grounds/permissions.json
+++ b/velocity/src/main/resources/META-INF/grounds/permissions.json
@@ -1,6 +1,14 @@
{
"source": "plugin-proxy",
"permissions": [
+ {
+ "key": "grounds.maintenance.bypass",
+ "label": "Bypass network maintenance",
+ "description": "Allows joining and remaining connected during network maintenance.",
+ "supportedScopes": [
+ "GLOBAL"
+ ]
+ },
{
"key": "grounds.proxy.debug",
"label": "Proxy diagnostics",
diff --git a/velocity/src/main/resources/META-INF/grounds/permissions/plugin-proxy.json b/velocity/src/main/resources/META-INF/grounds/permissions/plugin-proxy.json
index 3aa9b43..09b37f5 100644
--- a/velocity/src/main/resources/META-INF/grounds/permissions/plugin-proxy.json
+++ b/velocity/src/main/resources/META-INF/grounds/permissions/plugin-proxy.json
@@ -1,6 +1,14 @@
{
"source": "plugin-proxy",
"permissions": [
+ {
+ "key": "grounds.maintenance.bypass",
+ "label": "Bypass network maintenance",
+ "description": "Allows joining and remaining connected during network maintenance.",
+ "supportedScopes": [
+ "GLOBAL"
+ ]
+ },
{
"key": "grounds.proxy.debug",
"label": "Proxy diagnostics",
diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStoreTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStoreTest.kt
new file mode 100644
index 0000000..dbb382d
--- /dev/null
+++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStoreTest.kt
@@ -0,0 +1,111 @@
+package gg.grounds.proxy.velocity.maintenance
+
+import com.sun.net.httpserver.HttpServer
+import java.net.InetSocketAddress
+import java.net.URI
+import java.net.http.HttpClient
+import java.time.Duration
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTimeoutPreemptively
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+
+class MaintenanceConfigStoreTest {
+ @Test
+ fun `accepts canonical UUIDs of every version including floodgate UUIDs`() {
+ val document = MaintenanceConfigStore.parse(json("123e4567-e89b-12d3-a456-426614174000"))
+ assertEquals("123e4567-e89b-12d3-a456-426614174000", document.id.toString())
+ }
+
+ @Test
+ fun `normalizes UUID case and rejects fractional offsets`() {
+ assertEquals(
+ "123e4567-e89b-12d3-a456-426614174000",
+ MaintenanceConfigStore.parse(json("123E4567-E89B-12D3-A456-426614174000")).id.toString(),
+ )
+ assertThrows {
+ MaintenanceConfigStore.parse(
+ json("123e4567-e89b-12d3-a456-426614174000").replace("[60]", "[60.5]")
+ )
+ }
+ }
+
+ @Test
+ fun `rejects wrong scalar types and duplicate whitelist entries`() {
+ val valid = json("123e4567-e89b-12d3-a456-426614174000")
+ assertThrows {
+ MaintenanceConfigStore.parse(
+ valid.replace("\"schemaVersion\":1", "\"schemaVersion\":\"1\"")
+ )
+ }
+ assertThrows {
+ MaintenanceConfigStore.parse(
+ valid.replace(
+ "\"whitelist\":[]",
+ "\"whitelist\":[\"123e4567-e89b-12d3-a456-426614174000\",\"123e4567-e89b-12d3-a456-426614174000\"]",
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `a stalled response body times out and the next poll can recover`() {
+ val bodyStarted = CountDownLatch(1)
+ val release = CountDownLatch(1)
+ val requests = java.util.concurrent.atomic.AtomicInteger()
+ val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
+ server.createContext("/") { exchange ->
+ if (requests.getAndIncrement() > 0) {
+ exchange.sendResponseHeaders(404, -1)
+ exchange.close()
+ return@createContext
+ }
+ exchange.sendResponseHeaders(200, 1000)
+ exchange.responseBody.write("{".toByteArray())
+ exchange.responseBody.flush()
+ bodyStarted.countDown()
+ release.await(5, TimeUnit.SECONDS)
+ exchange.close()
+ }
+ server.start()
+ val http = HttpClient.newHttpClient()
+ val store =
+ MaintenanceConfigStore(
+ "velocity",
+ "stage",
+ URI("http://127.0.0.1:${server.address.port}"),
+ http,
+ Duration.ofMillis(750),
+ )
+ try {
+ assertTimeoutPreemptively(Duration.ofSeconds(3)) {
+ assertThrows { store.read() }
+ }
+ assertTrue(bodyStarted.await(1, TimeUnit.SECONDS))
+ release.countDown()
+ assertNull(store.read())
+ } finally {
+ release.countDown()
+ server.stop(0)
+ store.close()
+ }
+ }
+
+ @Test
+ fun `rejects shortened UUID forms instead of silently padding them`() {
+ assertThrows { MaintenanceConfigStore.parse(json("1-1-1-1-1")) }
+ assertThrows {
+ MaintenanceConfigStore.parse(
+ json("123e4567-e89b-12d3-a456-426614174000")
+ .replace("\"whitelist\":[]", "\"whitelist\":[\"1-1-1-1-1\"]")
+ )
+ }
+ }
+
+ private fun json(id: String) =
+ """{"schemaVersion":1,"enabled":false,"id":"$id","startsAt":"2026-10-19T20:00:00.000Z","endsAt":null,"estimatedEndAt":null,"reason":"work","joinMessage":"join","motd":"motd","kickExisting":true,"whitelist":[],"announcementSeconds":[60],"announcementMessage":"soon"}"""
+}
diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngineTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngineTest.kt
new file mode 100644
index 0000000..cec369d
--- /dev/null
+++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngineTest.kt
@@ -0,0 +1,190 @@
+package gg.grounds.proxy.velocity.maintenance
+
+import java.time.Clock
+import java.time.Instant
+import java.time.ZoneOffset
+import java.util.UUID
+import net.kyori.adventure.text.minimessage.MiniMessage
+import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class MaintenanceEngineTest {
+ private val id = UUID.fromString("00000000-0000-0000-0000-000000000001")
+ private val player = UUID.fromString("00000000-0000-0000-0000-000000000002")
+
+ @Test
+ fun `two proxies independently kick only their local non bypass players once`() {
+ val now = Instant.parse("2026-10-19T20:00:00Z")
+ val document = active(now)
+ val first = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC))
+ val second = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC))
+
+ first.adopt(document, now)
+ second.adopt(document, now)
+
+ assertTrue(first.shouldKick(player, bypass = false))
+ assertTrue(second.shouldKick(player, bypass = false))
+ assertFalse(first.shouldKick(player, bypass = false))
+ }
+
+ @Test
+ fun `permission and whitelist bypass permit joins and avoid kicks`() {
+ val now = Instant.parse("2026-10-19T20:00:00Z")
+ val whitelisted = UUID.fromString("00000000-0000-0000-0000-000000000003")
+ val engine = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC))
+ engine.adopt(active(now, whitelist = setOf(whitelisted)), now)
+
+ assertTrue(engine.admission(player, bypass = true).allowed)
+ assertTrue(engine.admission(whitelisted, bypass = false).allowed)
+ assertFalse(engine.shouldKick(player, bypass = true))
+ assertFalse(engine.shouldKick(whitelisted, bypass = false))
+ }
+
+ @Test
+ fun `listener access uses permission but whitelist skips unavailable provider`() {
+ val now = Instant.parse("2026-10-19T20:00:00Z")
+ val whitelisted = UUID.fromString("00000000-0000-0000-0000-000000000003")
+ val engine = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC))
+ engine.adopt(active(now, whitelist = setOf(whitelisted)), now)
+
+ assertTrue(MaintenanceAccess.admission(engine, player, { true }, now).allowed)
+ assertFalse(MaintenanceAccess.shouldKick(engine, player, { true }, now))
+ assertTrue(
+ MaintenanceAccess.admission(
+ engine,
+ whitelisted,
+ { error("permission provider must not be queried") },
+ now,
+ )
+ .allowed
+ )
+ assertFalse(
+ MaintenanceAccess.shouldKick(
+ engine,
+ whitelisted,
+ { error("permission provider must not be queried") },
+ now,
+ )
+ )
+ }
+
+ @Test
+ fun `disabled scheduled start and automatic expiry change effective state`() {
+ val start = Instant.parse("2026-10-19T20:00:00Z")
+ val clock = java.time.Clock.fixed(start.minusSeconds(1), ZoneOffset.UTC)
+ val engine = MaintenanceEngine(clock)
+ engine.adopt(active(start, endsAt = start.plusSeconds(60)), start.minusSeconds(1))
+ assertFalse(engine.activeAt(start.minusSeconds(1)))
+ assertTrue(engine.activeAt(start))
+ assertFalse(engine.activeAt(start.plusSeconds(60)))
+ assertTrue(engine.admission(player, bypass = false, now = start.plusSeconds(60)).allowed)
+ }
+
+ @Test
+ fun `invalid update keeps valid cache and stale cache denies ordinary joins only`() {
+ val now = Instant.parse("2026-10-19T20:00:00Z")
+ val engine = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC))
+ engine.adopt(active(now), now)
+ engine.refreshFailed(now.plusSeconds(31))
+
+ assertFalse(engine.admission(player, bypass = false, now = now.plusSeconds(31)).allowed)
+ assertTrue(engine.admission(player, bypass = true, now = now.plusSeconds(31)).allowed)
+ assertTrue(engine.activeAt(now.plusSeconds(31)))
+ }
+
+ @Test
+ fun `startup outage denies ordinary joins while still allowing bypass`() {
+ val now = Instant.parse("2026-10-19T20:00:00Z")
+ val engine = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC))
+
+ assertFalse(engine.admission(player, bypass = false, now = now).allowed)
+ assertTrue(engine.admission(player, bypass = true, now = now).allowed)
+ }
+
+ @Test
+ fun `editing an episode does not re kick but a new id does`() {
+ val now = Instant.parse("2026-10-19T20:00:00Z")
+ val engine = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC))
+ engine.adopt(active(now), now)
+ assertTrue(engine.shouldKick(player, false))
+ engine.adopt(active(now, reason = "new wording"), now.plusSeconds(1))
+ assertFalse(engine.shouldKick(player, false))
+ engine.adopt(
+ active(now, id = UUID.fromString("00000000-0000-0000-0000-000000000004")),
+ now.plusSeconds(2),
+ )
+ assertTrue(engine.shouldKick(player, false))
+ }
+
+ @Test
+ fun `message values are escaped once before MiniMessage rendering`() {
+ val now = Instant.parse("2026-10-19T20:00:00Z")
+ val engine = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC))
+ engine.adopt(
+ active(now, reason = "{end}\\path", endsAt = null)
+ .copy(joinMessage = "{reason} / {end}"),
+ now,
+ )
+ val rendered =
+ PlainTextComponentSerializer.plainText()
+ .serialize(MiniMessage.miniMessage().deserialize(engine.denialMessage(now)))
+ assertEquals("{end}\\path / to be announced", rendered)
+ }
+
+ @Test
+ fun `announcement thresholds are crossed once and never replayed after restart bootstrap`() {
+ val startsAt = Instant.parse("2026-10-19T20:10:00Z")
+ val engine = MaintenanceEngine(Clock.fixed(startsAt.minusSeconds(700), ZoneOffset.UTC))
+ engine.adopt(active(startsAt, offsets = listOf(600, 300)), startsAt.minusSeconds(700))
+ assertEquals(emptyList(), engine.announcementsAt(startsAt.minusSeconds(700)))
+ assertEquals(listOf(600L), engine.announcementsAt(startsAt.minusSeconds(599)))
+ assertEquals(emptyList(), engine.announcementsAt(startsAt.minusSeconds(590)))
+ }
+
+ @Test
+ fun `rescheduling same episode resets baseline without replaying past announcements`() {
+ val now = Instant.parse("2026-10-19T20:00:00Z")
+ val engine = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC))
+ engine.adopt(active(now.plusSeconds(700), offsets = listOf(600, 300)), now)
+ assertEquals(listOf(600L), engine.announcementsAt(now.plusSeconds(100)))
+ engine.adopt(active(now.plusSeconds(500), offsets = listOf(600, 300)), now.plusSeconds(101))
+ assertEquals(emptyList(), engine.announcementsAt(now.plusSeconds(102)))
+ }
+
+ @Test
+ fun `a removed maintenance document clears a previous active window`() {
+ val now = Instant.parse("2026-10-19T20:00:00Z")
+ val engine = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC))
+ engine.adopt(active(now), now)
+ assertFalse(engine.admission(player, false, now).allowed)
+ engine.adopt(null, now.plusSeconds(1))
+ assertTrue(engine.admission(player, false, now.plusSeconds(1)).allowed)
+ assertFalse(engine.shouldKick(player, false, now.plusSeconds(1)))
+ }
+
+ private fun active(
+ startsAt: Instant,
+ id: UUID = this.id,
+ endsAt: Instant? = null,
+ whitelist: Set = emptySet(),
+ reason: String = "Maintenance",
+ offsets: List = emptyList(),
+ ) =
+ MaintenanceDocument(
+ true,
+ id,
+ startsAt,
+ endsAt,
+ null,
+ reason,
+ "join",
+ "motd",
+ true,
+ whitelist,
+ offsets,
+ "announce",
+ )
+}