diff --git a/bridge/build.gradle.kts b/bridge/build.gradle.kts
new file mode 100644
index 00000000..78c6794b
--- /dev/null
+++ b/bridge/build.gradle.kts
@@ -0,0 +1,96 @@
+import org.apache.tools.ant.filters.ReplaceTokens
+
+plugins {
+ id("cygnus.java-conventions")
+ `maven-publish`
+}
+
+// Minestom extension that bridges CloudNet permission checks to LuckPerms. It is packaged as a
+// standalone extension jar (dropped into a CloudNet service's extensions/ folder next to the
+// CloudNet bridge) and never bundled into a fat jar. Everything it compiles against is provided at
+// runtime: the CloudNet driver by the CloudNet wrapper, the bridge by the CloudNet_Bridge
+// extension, Minestom and Adventure by the application classloader.
+dependencies {
+ compileOnly(platform(libs.aonyx.bom))
+ compileOnly(libs.minestom)
+ compileOnly(libs.adventure)
+ compileOnly(libs.minestom.ce.extensions)
+
+ compileOnly(platform(libs.cloudnet.bom))
+ compileOnly(libs.cloudnet.driver.api)
+ compileOnly(libs.cloudnet.bridge)
+ compileOnly(libs.cloudnet.bridge.impl)
+}
+
+// Stamp the version into extension.json (@version@ placeholder). Subprojects do not inherit the
+// root version, so read it from the root project - the same source the publications use.
+tasks.processResources {
+ val tokens = mapOf("version" to rootProject.version.toString())
+ inputs.properties(tokens)
+ filesMatching("extension.json") {
+ filter("tokens" to tokens)
+ }
+}
+
+publishing {
+ repositories {
+ maven {
+ authentication {
+ credentials(PasswordCredentials::class) {
+ // Those credentials need to be set under "Settings -> Secrets -> Actions" in your repository
+ username = System.getenv("ONELITEFEATHER_MAVEN_USERNAME")
+ password = System.getenv("ONELITEFEATHER_MAVEN_PASSWORD")
+ }
+ }
+ name = "OneLiteFeatherRepository"
+ url = if (rootProject.version.toString().contains("SNAPSHOT")) {
+ uri("https://repo.onelitefeather.dev/onelitefeather-snapshots")
+ } else {
+ uri("https://repo.onelitefeather.dev/onelitefeather-releases")
+ }
+ }
+ }
+ publications {
+ create("maven") {
+ artifact(project.tasks.getByName("jar"))
+ version = rootProject.version as String
+ artifactId = "cygnus-bridge"
+ groupId = rootProject.group as String
+ pom {
+ description.set("CloudNet bridge extension that resolves permissions through LuckPerms")
+ name = "Cygnus Bridge Component"
+ url = "https://github.com/OneLiteFeatherNET/Cygnus"
+ licenses {
+ license {
+ name = "AGPL-3.0 License"
+ url = "https://www.gnu.org/licenses/agpl-3.0.en.html"
+ }
+ }
+ developers {
+ developer {
+ name.set("OneliteFeather")
+ contributors {
+ contributor {
+ name.set("theEvilReaper")
+ }
+ contributor {
+ name.set("TheMeinerLP")
+ }
+ }
+ }
+ }
+
+ issueManagement {
+ system.set("Github")
+ url.set("https://github.com/OneLiteFeatherNET/Cygnus/issues")
+ }
+
+ scm {
+ connection = "scm:git:git://github.com:OneLiteFeatherNET/Cygnus.git"
+ developerConnection = "scm:git:ssh://git@github.com:OneLiteFeatherNET/Cygnus.git"
+ url = "https://github.com/OneLiteFeatherNET/Cygnus"
+ }
+ }
+ }
+ }
+}
diff --git a/bridge/src/main/java/net/onelitefeather/cygnus/bridge/CygnusBridgePermissionExtension.java b/bridge/src/main/java/net/onelitefeather/cygnus/bridge/CygnusBridgePermissionExtension.java
new file mode 100644
index 00000000..72802dd3
--- /dev/null
+++ b/bridge/src/main/java/net/onelitefeather/cygnus/bridge/CygnusBridgePermissionExtension.java
@@ -0,0 +1,43 @@
+package net.onelitefeather.cygnus.bridge;
+
+import eu.cloudnetservice.driver.registry.ServiceRegistry;
+import eu.cloudnetservice.modules.bridge.impl.platform.minestom.MinestomPermissionChecker;
+import net.kyori.adventure.permission.PermissionChecker;
+import net.kyori.adventure.util.TriState;
+import net.minestom.server.extensions.Extension;
+
+/**
+ * Minestom extension that teaches the CloudNet bridge how Cygnus resolves permissions.
+ *
+ * The bridge ships a default checker that only inspects {@code player.getPermissionLevel()}, which
+ * is always {@code 0} on a LuckPerms-managed server — maintenance bypass and task-level
+ * {@code requiredPermission} checks would therefore reject every player, staff included. This
+ * extension registers a checker that reads Adventure's {@link PermissionChecker#POINTER} instead,
+ * the same pointer LuckPerms and our {@code /stop} command read, and marks it the registry default.
+ *
+ * {@link MinestomPermissionChecker} only exists inside the CloudNet bridge's extension classloader,
+ * so this glue cannot live in the application. Declaring a dependency on the {@code CloudNet_Bridge}
+ * extension (see {@code extension.json}) makes this extension load after the bridge and share its
+ * classloader hierarchy. Minestom and Adventure come from the application classloader above, so the
+ * pointer read here is the very one the player carries.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.6.7
+ **/
+public final class CygnusBridgePermissionExtension extends Extension {
+
+ @Override
+ public void initialize() {
+ MinestomPermissionChecker checker = (player, permission) ->
+ player.getOrDefault(PermissionChecker.POINTER, PermissionChecker.always(TriState.FALSE))
+ .test(permission);
+ ServiceRegistry.registry()
+ .registerProvider(MinestomPermissionChecker.class, "cygnus-luckperms", checker)
+ .markAsDefaultService();
+ }
+
+ @Override
+ public void terminate() {
+ }
+}
diff --git a/bridge/src/main/resources/extension.json b/bridge/src/main/resources/extension.json
new file mode 100644
index 00000000..04c06719
--- /dev/null
+++ b/bridge/src/main/resources/extension.json
@@ -0,0 +1,7 @@
+{
+ "name": "CygnusCloudNetPermissions",
+ "version": "@version@",
+ "entrypoint": "net.onelitefeather.cygnus.bridge.CygnusBridgePermissionExtension",
+ "authors": ["OneLiteFeather"],
+ "dependencies": ["CloudNet_Bridge"]
+}
diff --git a/common/build.gradle.kts b/common/build.gradle.kts
index 24f4c0f1..09bab65f 100644
--- a/common/build.gradle.kts
+++ b/common/build.gradle.kts
@@ -15,6 +15,9 @@ dependencies {
}
testImplementation(libs.minestom)
+ testImplementation(libs.luckperms.api) {
+ exclude(group = "net.kyori.adventure")
+ }
testImplementation(libs.cyano)
testImplementation(libs.aves)
testImplementation(libs.xerus)
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/bootstrap/StopCommand.java b/common/src/main/java/net/onelitefeather/cygnus/common/bootstrap/StopCommand.java
index bd80f76c..5ba30765 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/bootstrap/StopCommand.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/bootstrap/StopCommand.java
@@ -1,8 +1,9 @@
package net.onelitefeather.cygnus.common.bootstrap;
-import net.luckperms.api.LuckPermsProvider;
-import net.luckperms.api.model.user.User;
+import net.kyori.adventure.permission.PermissionChecker;
+import net.kyori.adventure.util.TriState;
import net.minestom.server.MinecraftServer;
+import net.minestom.server.command.CommandSender;
import net.minestom.server.command.builder.Command;
import net.minestom.server.entity.Player;
@@ -11,7 +12,7 @@
* players holding {@value #PERMISSION}, since a service should not be stoppable by regular players.
*
* @author TheMeinerLP
- * @version 1.0.0
+ * @version 1.1.0
* @since 2.6.7
**/
public final class StopCommand extends Command {
@@ -23,7 +24,7 @@ public final class StopCommand extends Command {
*/
public StopCommand() {
super("stop");
- setCondition((sender, commandString) -> !(sender instanceof Player player) || hasStopPermission(player));
+ setCondition((sender, commandString) -> !(sender instanceof Player) || hasStopPermission(sender));
setDefaultExecutor((sender, context) -> Thread.ofPlatform().name("cygnus-shutdown").start(() -> {
MinecraftServer.stopCleanly();
System.exit(0);
@@ -31,17 +32,16 @@ public StopCommand() {
}
/**
- * Checks whether the given player is allowed to run this command via LuckPerms.
+ * Checks whether the given sender is allowed to run this command.
*
- * Assumes LuckPerms has already been bootstrapped (see {@code MinestomLoader}), which is
- * guaranteed by the time a player can connect and send commands.
+ * Reads Adventure's {@link PermissionChecker#POINTER}, which our player implementation backs
+ * with LuckPerms (see {@code PermissionAwarePlayer}). A sender without that pointer is denied.
*
- * @param player the player to check
- * @return {@code true} if the player holds {@value #PERMISSION}, {@code false} otherwise
- * (including when LuckPerms has no cached data for the player yet)
+ * @param sender the sender to check
+ * @return {@code true} if the sender holds {@value #PERMISSION}, {@code false} otherwise
*/
- private static boolean hasStopPermission(Player player) {
- User user = LuckPermsProvider.get().getUserManager().getUser(player.getUuid());
- return user != null && user.getCachedData().getPermissionData().checkPermission(PERMISSION).asBoolean();
+ private static boolean hasStopPermission(CommandSender sender) {
+ return sender.getOrDefault(PermissionChecker.POINTER, PermissionChecker.always(TriState.FALSE))
+ .test(PERMISSION);
}
}
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/permission/TriStates.java b/common/src/main/java/net/onelitefeather/cygnus/common/permission/TriStates.java
new file mode 100644
index 00000000..1eae35c6
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/permission/TriStates.java
@@ -0,0 +1,32 @@
+package net.onelitefeather.cygnus.common.permission;
+
+import net.kyori.adventure.util.TriState;
+import net.luckperms.api.util.Tristate;
+
+/**
+ * Converts between LuckPerms' and Adventure's tri-state types, which model the same three values
+ * under two unrelated types.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.6.7
+ **/
+public final class TriStates {
+
+ private TriStates() {
+ }
+
+ /**
+ * Converts a LuckPerms tri-state into its Adventure counterpart.
+ *
+ * @param tristate the LuckPerms value to convert
+ * @return the matching Adventure value, where {@code UNDEFINED} maps to {@code NOT_SET}
+ */
+ public static TriState fromLuckPerms(Tristate tristate) {
+ return switch (tristate) {
+ case TRUE -> TriState.TRUE;
+ case FALSE -> TriState.FALSE;
+ case UNDEFINED -> TriState.NOT_SET;
+ };
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/player/InstanceSwitchChunkPlayer.java b/common/src/main/java/net/onelitefeather/cygnus/common/player/InstanceSwitchChunkPlayer.java
index 46152dca..0b77244d 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/player/InstanceSwitchChunkPlayer.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/player/InstanceSwitchChunkPlayer.java
@@ -35,13 +35,15 @@
*
*
Remove this class once the server runs a Minestom build that contains PR #3308 — as of
* {@code 2026.07.22-26.2} the newest published build predates the merge. Upstream itself intends to
- * revert the workaround for 26.3, where the client bug is fixed.
+ * revert the workaround for 26.3, where the client bug is fixed. When that happens, let the
+ * subclasses extend {@link PermissionAwarePlayer} directly — the permission pointer it installs is
+ * unrelated to this workaround and must survive its removal.
*
* @author TheMeinerLP
* @version 1.0.0
* @since 2.6.7
*/
-public abstract class InstanceSwitchChunkPlayer extends Player {
+public abstract class InstanceSwitchChunkPlayer extends PermissionAwarePlayer {
private volatile @Nullable TargetView targetView;
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/player/PermissionAwarePlayer.java b/common/src/main/java/net/onelitefeather/cygnus/common/player/PermissionAwarePlayer.java
new file mode 100644
index 00000000..838b4e58
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/player/PermissionAwarePlayer.java
@@ -0,0 +1,70 @@
+package net.onelitefeather.cygnus.common.player;
+
+import net.kyori.adventure.permission.PermissionChecker;
+import net.kyori.adventure.pointer.Pointers;
+import net.kyori.adventure.util.TriState;
+import net.luckperms.api.LuckPermsProvider;
+import net.luckperms.api.model.user.User;
+import net.luckperms.api.query.QueryOptions;
+import net.minestom.server.entity.Player;
+import net.minestom.server.network.player.GameProfile;
+import net.minestom.server.network.player.PlayerConnection;
+import net.onelitefeather.cygnus.common.permission.TriStates;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * A {@link Player} that answers permission questions through LuckPerms.
+ *
+ * Minestom has no permission system of its own — it only carries Adventure's
+ * {@link PermissionChecker#POINTER}, and everything that asks about permissions reads it from
+ * there: LuckPerms' own command sender factory, our {@code /stop} command, and the CloudNet bridge
+ * extension. Neither Minestom nor LuckPerms ever installs that pointer, though; the server
+ * implementation has to supply it. Without it every permission check silently resolves to
+ * {@code false}, which would lock staff out of CloudNet maintenance mode just like everyone else.
+ *
+ * The pointer is dynamic, so no LuckPerms class is touched until a permission is actually queried.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.6.7
+ **/
+public abstract class PermissionAwarePlayer extends Player implements PermissionChecker {
+
+ private final @NotNull Pointers pointers = PermissionAwarePlayer.super.pointers()
+ .toBuilder()
+ .withDynamic(PermissionChecker.POINTER, () -> this)
+ .build();
+
+ /**
+ * {@inheritDoc}
+ */
+ protected PermissionAwarePlayer(PlayerConnection playerConnection, GameProfile gameProfile) {
+ super(playerConnection, gameProfile);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Pointers pointers() {
+ return this.pointers;
+ }
+
+ /**
+ * Resolves a permission for this player through LuckPerms, honouring the contexts LuckPerms
+ * has calculated for them.
+ *
+ * @param permission the permission node to check
+ * @return the value LuckPerms holds for the node, or {@link TriState#FALSE} when LuckPerms has
+ * no user data for this player
+ */
+ @Override
+ public @NotNull TriState value(@NotNull String permission) {
+ User user = LuckPermsProvider.get().getUserManager().getUser(getUuid());
+ if (user == null) {
+ return TriState.FALSE;
+ }
+ QueryOptions queryOptions = LuckPermsProvider.get().getContextManager().getQueryOptions(this);
+ return TriStates.fromLuckPerms(user.getCachedData().getPermissionData(queryOptions).checkPermission(permission));
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/permission/TriStatesTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/permission/TriStatesTest.java
new file mode 100644
index 00000000..d39d4ae9
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/permission/TriStatesTest.java
@@ -0,0 +1,34 @@
+package net.onelitefeather.cygnus.common.permission;
+
+import net.kyori.adventure.util.TriState;
+import net.luckperms.api.util.Tristate;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class TriStatesTest {
+
+ @Test
+ void testConvertTrue() {
+ assertEquals(TriState.TRUE, TriStates.fromLuckPerms(Tristate.TRUE));
+ }
+
+ @Test
+ void testConvertFalse() {
+ assertEquals(TriState.FALSE, TriStates.fromLuckPerms(Tristate.FALSE));
+ }
+
+ @Test
+ void testConvertUndefined() {
+ assertEquals(TriState.NOT_SET, TriStates.fromLuckPerms(Tristate.UNDEFINED));
+ }
+
+ @ParameterizedTest
+ @EnumSource(Tristate.class)
+ void testEveryValueIsMapped(Tristate tristate) {
+ assertNotNull(TriStates.fromLuckPerms(tristate));
+ }
+}
diff --git a/docs/cloudnet-deployment.md b/docs/cloudnet-deployment.md
new file mode 100644
index 00000000..205d8628
--- /dev/null
+++ b/docs/cloudnet-deployment.md
@@ -0,0 +1,81 @@
+# CloudNet deployment
+
+How to deploy the `game` and `setup` services as CloudNet services.
+
+## Artifacts
+
+| Artifact | Maven | Goes to |
+|---|---|---|
+| `cygnus.jar` | `cygnus-game` | service root (application file) |
+| `setup.jar` | `cygnus-setup` | service root (application file) |
+| `bridge.jar` | `cygnus-bridge` | `extensions/` |
+
+## How CloudNet starts the service
+
+```
+java … -javaagent:
+ -Dservice.bind.host= -Dservice.bind.port=
+ -cp :
+```
+
+- The application jar is launched through `-cp` plus its manifest `Main-Class`, not through `-jar`. Both jars end
+ up in one classloader, which is why no CloudNet artifact may ever be bundled into the fat jar — everything
+ CloudNet-related stays `compileOnly`.
+- `service.bind.host` / `service.bind.port` are always set by the node. Standalone runs fall back to
+ `localhost:25565`; no system properties are needed for local testing.
+- To stop a service the node writes `end` and then `stop` to stdin. `stop` triggers a clean shutdown;
+ `end` is not a registered command and is ignored.
+
+## Service directory layout
+
+Everything except `data/` belongs in the CloudNet template. `data/` is created by LuckPerms on first start and
+holds its config and H2 database — it is per service and must not be shared between services.
+
+```
+/ /
+├── cygnus.jar ├── setup.jar
+├── extensions/ ├── extensions/
+│ ├── CloudNet-Bridge.jar │ ├── CloudNet-Bridge.jar
+│ └── bridge.jar │ └── bridge.jar
+├── game/maps/