Compare commits

...
13 Commits
Author SHA1 Message Date
Hare 2e35905599 bump 26.1 paper-api & jvmToolchain 2026-04-27 17:35:35 +09:00
Hare 3c60d4a433 bump 26.1 2026-04-27 17:32:50 +09:00
Hare 9673e1e6e9 feat: Folia support 2026-03-11 17:15:56 +09:00
Hare 7eb0534d21 chore: fmt/lint 2026-03-03 22:18:42 +09:00
Hare 72312a45e0 chore: paperLibrary対応 2025-12-09 18:18:15 +09:00
Hare 90bad7f37c chore: ignoreの追加 2025-12-07 04:03:40 +09:00
Hare 660f9a3436 feat: wildcard excludeの実装 2025-12-05 01:01:13 +09:00
Hare 2275cd9993 chore: 未使用の変数を削除 2025-12-04 12:37:40 +09:00
Hare 00d5860457 fix: wildcardの挙動を修正 2025-12-04 06:44:59 +09:00
Hare a6e951b48c feat: tagの削除とwildcard伝播 2025-12-04 06:23:16 +09:00
Hare 58ad0e67c9 feat: 構造ノードの定義を可能に 2025-12-04 05:13:56 +09:00
Hare a4f6e8e236 feat: namespaceの扱いの改善 2025-12-04 04:41:37 +09:00
Hare 29dc1f10dd chore: ビルド設定の変更 2025-11-29 04:26:32 +09:00
20 changed files with 419 additions and 183 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
.direnv
.gradle
bin
build
+52 -31
View File
@@ -8,37 +8,41 @@ instances and keep `PermissionAttachment`s in sync.
## Usage
```kotlin
import net.hareworks.permits_lib.domain.NodeRegistration
class ExamplePlugin : JavaPlugin() {
private val permits = PermitsLib.session(this)
override fun onEnable() {
val tree = permissionTree("example") {
node("command") {
node("command", NodeRegistration.STRUCTURAL) {
description = "Access to all example commands"
defaultValue = PermissionDefault.OP
wildcard {
exclude("cooldown") // example.command.* will skip cooldown
}
node("reload") {
node("reload", NodeRegistration.PERMISSION) {
description = "Allows /example reload (permission example.command.reload)"
}
// Link to a helper node defined elsewhere under the command branch:
child("helper")
// Link to a permission outside the current branch by using the absolute helper:
childAbsolute("tools.repair")
// Link to a permission outside the current branch (must be fully-qualified):
childAbsolute("example.tools.repair")
node("cooldown") {
node("cooldown", NodeRegistration.PERMISSION) {
description = "Allows /example cooldown tweaks"
wildcard = false // opt-out if you do not want example.command.* to include it
}
}
node("command.helper") {
node("command.helper", NodeRegistration.PERMISSION) {
description = "Allows /example helper (referenced via child(\"helper\"))"
}
node("tools.repair") {
description = "Allows /example tools repair (linked with childAbsolute)"
node("tools.repair", NodeRegistration.PERMISSION) {
description = "Allows /example tools repair (linked with childAbsolute(\"example.tools.repair\"))"
}
}
@@ -47,7 +51,7 @@ class ExamplePlugin : JavaPlugin() {
// The tree above materializes as permissions such as:
// example.command, example.command.reload, example.command.helper, example.command.cooldown,
// example.tools.repair,
// plus the auto-generated example.command.* wildcard that references every child (visible if you
// plus the auto-generated example.command.* wildcard (command opted in, cooldown was excluded).
// export to plugin.yml or inspect Bukkit's /permissions output).
configureRuntimePermissions()
@@ -61,15 +65,17 @@ class ExamplePlugin : JavaPlugin() {
// Later in runtime you can mutate the previously applied structure without rebuilding it:
permits.edit("example") {
// Update an existing node and link it to new children
node("command") {
node("command", NodeRegistration.STRUCTURAL) {
description = "Admins for every command path"
node("debug") {
wildcard = true
node("debug", NodeRegistration.PERMISSION) {
description = "Allows /example debug"
defaultValue = PermissionDefault.OP
wildcard = true
}
}
// Remove deprecated permissions entirely
remove("command.cooldown")
removeNode("command.cooldown")
}
}
}
@@ -82,45 +88,60 @@ mutate it procedurally, and then apply the result:
```kotlin
val baseTree = permissionTree("example") {
node("command") { node("reload") }
node("command", NodeRegistration.STRUCTURAL) {
wildcard = true
node("reload", NodeRegistration.PERMISSION)
}
}
val mutable = MutablePermissionTree.from(baseTree)
mutable.node("command") {
node("debug") {
mutable.node("command", NodeRegistration.STRUCTURAL) {
wildcard = true
excludeWildcardChild("helper") // keep helper out of command.*
node("debug", NodeRegistration.PERMISSION) {
description = "Allows /example debug"
defaultValue = PermissionDefault.OP
wildcard = true
}
child("helper", value = false) // unlink helper if present
}
mutable.remove("command.legacy")
mutable.removeNode("command.legacy")
permits.applyTree(mutable.build())
```
The mutable API mirrors the DSL (`node`, `child`, `childAbsolute`, `remove`, `removeChild`, etc.) so you can
The mutable API mirrors the DSL (`node`, `child`, `childAbsolute`, `removeNode`, `renameNode`, etc.) so you can
stage edits procedurally before ever touching `MutationSession`.
### Concepts
- **Permission tree** immutable graph of `PermissionNode`s. Nodes specify description, default value,
boolean children map, optional tags, and the `wildcard` flag (enabled by default) that makes the library
create/update `namespace.path.*` aggregate permissions automatically.
- **DSL** `permissionTree("namespace") { ... }` ensures consistent prefixes and validation (no cycles).
- **Nested nodes** `node("command") { node("reload") { ... } }` automatically produces
boolean children map, and the `wildcard` flag (disabled by default) that, when enabled per node, keeps
`namespace.path.*` aggregate permissions in sync automatically.
- **DSL** `permissionTree("namespace") { ... }` ensures consistent prefixes and validation (no cycles). Every `node("command", NodeRegistration.PERMISSION)` (or `.STRUCTURAL`) is relative to that namespace, so you never include the namespace manually at the root.
- **Nested nodes** `node("command", NodeRegistration.STRUCTURAL) { node("reload", NodeRegistration.PERMISSION) { ... } }` automatically produces
`namespace.command` and `namespace.command.reload` plus wires the parent/child relationship so you don't
have to repeat the full id.
- **Flexible references** `child("reload")`, `node("command") { node("reload") { ... } }`, or
even `node("command.reload")` inside `edit` all resolve to the same node; children are auto-created on
- **Flexible references** `child("reload")`, `node("command", NodeRegistration.STRUCTURAL) { node("reload", NodeRegistration.PERMISSION) { ... } }`, or
even `node("command.reload", NodeRegistration.PERMISSION)` inside `edit` all resolve to the same node; children are auto-created on
first reference but you can demand explicit nodes by adding a `node` block later, and you can unlink
specific children via `node("command") { removeChild("cooldown") }` without deleting the underlying node.
Nested `child(...)` calls are relative to the current node by default, while `childAbsolute(...)` lets you
point at any fully-qualified permission ID within the namespace.
specific children via `node("command", NodeRegistration.STRUCTURAL) { removeNode("cooldown") }` and the entire subtree disappears.
- **Node registration** `NodeRegistration.PERMISSION` materializes the node as a Bukkit permission, while `NodeRegistration.STRUCTURAL` keeps it purely for grouping (still participates in wildcard aggregation) so you can avoid ambiguous intermediate permissions like `hoge.command`.
Nested `child(...)` calls are relative to the current node by default, while `childAbsolute(...)` now
expects a fully-qualified permission ID (e.g., `example.tools.repair`) so you can also point at nodes in
other namespaces.
- **PermissionRegistry** calculates a diff between snapshots and performs the minimum additions,
removals, or updates via Bukkit's `PluginManager`.
- **Wildcards** with `wildcard = true`, the generated `namespace.command.*` child always exists and stays
in sync so granting `example.command.*` automatically grants every nested node; set it to `false` to opt
out for specific permissions.
- **Wildcards** disabled by default; opt in via `wildcard = true` or the richer `wildcard { ... }` block.
The block automatically enables the wildcard and lets you `exclude("sub.path")` so only selected DSL
children end up under `namespace.command.*`. Enabled nodes automatically add their wildcard descendants
(e.g., `example.command.debug.*`) so granting the wildcard cascades to the remaining children.
### Selective wildcards
- **DSL** call `wildcard { exclude("cooldown") }` to enable the `*. *` permission while skipping specific
literal/argument branches. You can chain `exclude` calls and pass multi-segment paths (`exclude("debug.logs")`).
- **Mutable tree** after `wildcard = true`, invoke `excludeWildcardChild("helper")` (relative) or
`excludeWildcardChildAbsolute("example.command.helper.extras")` to trim wildcard membership imperatively.
- **Mutable edits** `permits.edit { ... }` clones the currently registered tree, lets you mutate nodes
imperatively, re-validates, and only pushes the structural diff to Bukkit.
- **AttachmentSynchronizer** manages identity-based `PermissionAttachment`s and exposes high-level
+18 -13
View File
@@ -1,11 +1,9 @@
import net.minecrell.pluginyml.bukkit.BukkitPluginDescription
group = "net.hareworks"
version = "1.1"
plugins {
kotlin("jvm") version "2.2.21"
id("de.eldoria.plugin-yml.paper") version "0.8.0"
kotlin("jvm") version "2.3.21"
id("de.eldoria.plugin-yml.paper") version "0.9.0"
id("com.gradleup.shadow") version "9.2.2"
}
repositories {
@@ -13,15 +11,22 @@ repositories {
maven("https://repo.papermc.io/repository/maven-public/")
}
val exposedVersion = "1.0.0-rc-3"
dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT")
compileOnly("io.papermc.paper:paper-api:26.1.2.build.49-beta")
paperLibrary("org.jetbrains.kotlin:kotlin-stdlib")
}
kotlin {
jvmToolchain(25)
}
tasks {
shadowJar {
withType<Jar> {
archiveBaseName.set("Permits-Lib")
archiveClassifier.set("")
}
shadowJar {
minimize()
archiveClassifier.set("min")
}
}
@@ -30,9 +35,9 @@ paper {
name = "permits-lib"
description = "Permission Library"
version = getVersion().toString()
apiVersion = "1.21.10"
apiVersion = "26.1"
authors =
listOf(
"Hare-K02"
)
listOf(
"Hare-K02",
)
}
@@ -3,4 +3,4 @@ package net.hareworks.permits_lib.plugin
import org.bukkit.plugin.java.JavaPlugin
@Suppress("unused")
class Plugin : JavaPlugin() {}
class Plugin : JavaPlugin()
@@ -7,7 +7,7 @@ import net.hareworks.permits_lib.domain.PermissionId
* `true`/`false` represent forced grant/deny, while `null` removes the override.
*/
data class AttachmentPatch(
val changes: Map<PermissionId, Boolean?>
val changes: Map<PermissionId, Boolean?>,
) {
companion object {
val EMPTY = AttachmentPatch(emptyMap())
@@ -1,28 +1,31 @@
package net.hareworks.permits_lib.bukkit
import java.util.IdentityHashMap
import net.hareworks.permits_lib.domain.PermissionId
import net.hareworks.permits_lib.util.ThreadChecks
import org.bukkit.permissions.PermissionAttachment
import org.bukkit.permissions.Permissible
import org.bukkit.permissions.PermissionAttachment
import org.bukkit.plugin.java.JavaPlugin
import java.util.IdentityHashMap
/**
* Manages [PermissionAttachment] instances per [Permissible], applying patches and cleaning up once no
* overrides remain.
*/
class AttachmentSynchronizer(
private val plugin: JavaPlugin
private val plugin: JavaPlugin,
) {
private data class AttachmentHandle(
val attachment: PermissionAttachment,
val overrides: MutableMap<PermissionId, Boolean> = linkedMapOf()
val overrides: MutableMap<PermissionId, Boolean> = linkedMapOf(),
)
private val handles = IdentityHashMap<Permissible, AttachmentHandle>()
fun applyPatch(permissible: Permissible, patch: AttachmentPatch) {
ThreadChecks.ensurePrimaryThread("AttachmentSynchronizer.applyPatch")
fun applyPatch(
permissible: Permissible,
patch: AttachmentPatch,
) {
ThreadChecks.ensureRegionThread("AttachmentSynchronizer.applyPatch", permissible)
if (patch.changes.isEmpty()) return
val handle = ensureHandle(permissible)
patch.changes.forEach { (id, value) ->
@@ -39,16 +42,23 @@ class AttachmentSynchronizer(
}
}
fun grant(permissible: Permissible, permission: PermissionId, value: Boolean = true) {
fun grant(
permissible: Permissible,
permission: PermissionId,
value: Boolean = true,
) {
applyPatch(permissible, AttachmentPatch(mapOf(permission to value)))
}
fun revoke(permissible: Permissible, permission: PermissionId) {
fun revoke(
permissible: Permissible,
permission: PermissionId,
) {
applyPatch(permissible, AttachmentPatch(mapOf(permission to null)))
}
fun clear(permissible: Permissible) {
ThreadChecks.ensurePrimaryThread("AttachmentSynchronizer.clear")
ThreadChecks.ensureRegionThread("AttachmentSynchronizer.clear", permissible)
handles.remove(permissible)?.attachment?.remove()
}
@@ -9,7 +9,7 @@ import net.hareworks.permits_lib.domain.TreeDiff
*/
class MutationSession(
private val registry: PermissionRegistry,
val attachments: AttachmentSynchronizer
val attachments: AttachmentSynchronizer,
) {
private var tree: PermissionTree? = null
private var diff: TreeDiff? = null
@@ -34,19 +34,23 @@ class MutationSession(
* Mutates the existing tree or creates a fresh one for the provided [namespace] when none was applied
* before.
*/
fun edit(namespace: String, block: MutablePermissionTree.() -> Unit): TreeDiff {
val mutable = tree?.let {
require(it.namespace == namespace) {
"Existing tree namespace '${it.namespace}' differs from requested '$namespace'."
}
MutablePermissionTree.from(it)
} ?: MutablePermissionTree.create(namespace)
fun edit(
namespace: String,
block: MutablePermissionTree.() -> Unit,
): TreeDiff {
val mutable =
tree?.let {
require(it.namespace == namespace) {
"Existing tree namespace '${it.namespace}' differs from requested '$namespace'."
}
MutablePermissionTree.from(it)
} ?: MutablePermissionTree.create(namespace)
return editInternal(mutable, block)
}
private fun editInternal(
mutable: MutablePermissionTree,
block: MutablePermissionTree.() -> Unit
block: MutablePermissionTree.() -> Unit,
): TreeDiff {
mutable.block()
val next = mutable.build()
@@ -61,13 +65,14 @@ class MutationSession(
}
fun currentTree(): PermissionTree? = tree
fun lastDiff(): TreeDiff? = diff
companion object {
fun create(plugin: org.bukkit.plugin.java.JavaPlugin): MutationSession =
MutationSession(
registry = PermissionRegistry(plugin),
attachments = AttachmentSynchronizer(plugin)
attachments = AttachmentSynchronizer(plugin),
)
}
}
@@ -16,7 +16,7 @@ import org.bukkit.plugin.java.JavaPlugin
*/
class PermissionRegistry(
private val plugin: JavaPlugin,
private val pluginManager: PluginManager = plugin.server.pluginManager
private val pluginManager: PluginManager = plugin.server.pluginManager,
) {
private var snapshot: TreeSnapshot? = null
@@ -8,43 +8,50 @@ import org.bukkit.permissions.PermissionDefault
*/
class MutablePermissionTree internal constructor(
private val namespace: String,
private val drafts: MutableMap<PermissionId, PermissionNodeDraft>
private val drafts: MutableMap<PermissionId, PermissionNodeDraft>,
) {
fun node(id: String, block: MutableNode.() -> Unit = {}): MutableNode {
val permissionId = PermissionId.of(qualify(id))
fun node(
id: String,
registration: NodeRegistration,
block: MutableNode.() -> Unit = {},
): MutableNode {
require(id.isNotBlank()) { "Node id must not be blank." }
val permissionId = PermissionId.of("$namespace.${id.lowercase()}")
val draft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) }
draft.registration = registration
return MutableNode(permissionId, draft).apply(block)
}
fun remove(id: String) {
val permissionId = PermissionId.of(qualify(id))
drafts.remove(permissionId)
drafts.values.forEach { it.children.remove(permissionId) }
fun removeNode(id: String) {
require(id.isNotBlank()) { "Node id must not be blank." }
val permissionId = PermissionId.of("$namespace.${id.lowercase()}")
removeSubtree(permissionId)
}
fun contains(id: String): Boolean = drafts.containsKey(PermissionId.of(qualify(id)))
fun renameNode(
oldId: String,
newId: String,
) {
require(oldId.isNotBlank()) { "Old node id must not be blank." }
require(newId.isNotBlank()) { "New node id must not be blank." }
val oldPermissionId = PermissionId.of("$namespace.${oldId.lowercase()}")
val newPermissionId = PermissionId.of("$namespace.${newId.lowercase()}")
renameSubtree(oldPermissionId, newPermissionId)
}
fun contains(id: String): Boolean {
require(id.isNotBlank()) { "Node id must not be blank." }
return drafts.containsKey(PermissionId.of("$namespace.${id.lowercase()}"))
}
fun build(): PermissionTree {
val nodes = drafts.mapValues { it.value.toNode() }
return PermissionTree.from(namespace, nodes)
}
private fun qualify(id: String): String =
if (id.startsWith(namespace)) id else "$namespace.$id"
private fun qualifyRelative(parent: PermissionId, childSegment: String): String {
val normalized = childSegment.trim().lowercase().trimStart('.')
require(normalized.isNotEmpty()) { "Child id must not be blank." }
return if (normalized.startsWith(namespace)) {
normalized
} else {
"${parent.value}.$normalized"
}
}
inner class MutableNode internal constructor(
val id: PermissionId,
private val draft: PermissionNodeDraft
private val draft: PermissionNodeDraft,
) {
var description: String?
get() = draft.description
@@ -64,45 +71,151 @@ class MutablePermissionTree internal constructor(
draft.wildcard = value
}
val tags: MutableSet<String>
get() = draft.tags
fun tag(value: String) {
if (value.isNotBlank()) {
draft.tags += value.trim()
var registration: NodeRegistration
get() = draft.registration
set(value) {
draft.registration = value
}
}
fun child(id: String, value: Boolean = true) {
val permissionId = PermissionId.of(qualifyRelative(this.id, id))
fun child(
id: String,
value: Boolean = true,
) {
require(id.isNotBlank()) { "Child id must not be blank." }
val permissionId = PermissionId.of("${this.id.value}.${id.lowercase()}")
draft.children[permissionId] = value
}
fun childAbsolute(id: String, value: Boolean = true) {
val permissionId = PermissionId.of(qualify(id))
fun childAbsolute(
id: String,
value: Boolean = true,
) {
val permissionId = PermissionId.of(id.lowercase())
draft.children[permissionId] = value
}
fun node(id: String, block: MutableNode.() -> Unit = {}) {
val permissionId = PermissionId.of(qualifyRelative(this.id, id))
fun node(
id: String,
registration: NodeRegistration,
block: MutableNode.() -> Unit = {},
) {
require(id.isNotBlank()) { "Node id must not be blank." }
val permissionId = PermissionId.of("${this.id.value}.${id.lowercase()}")
draft.children[permissionId] = true
val childDraft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) }
childDraft.registration = registration
MutableNode(permissionId, childDraft).apply(block)
}
fun removeChild(id: String) {
draft.children.remove(PermissionId.of(qualify(id)))
fun removeNode(id: String) {
require(id.isNotBlank()) { "Node id must not be blank." }
val permissionId = PermissionId.of("${this.id.value}.${id.lowercase()}")
removeSubtree(permissionId)
}
fun renameNode(
oldId: String,
newId: String,
) {
require(oldId.isNotBlank()) { "Old node id must not be blank." }
require(newId.isNotBlank()) { "New node id must not be blank." }
val oldPermissionId = PermissionId.of("${this.id.value}.${oldId.lowercase()}")
val newPermissionId = PermissionId.of("${this.id.value}.${newId.lowercase()}")
renameSubtree(oldPermissionId, newPermissionId)
}
fun excludeWildcardChild(id: String) {
require(id.isNotBlank()) { "Wildcard exclusion id must not be blank." }
val permissionId = PermissionId.of("${this.id.value}.${id.lowercase()}")
draft.wildcardExclusions.add(permissionId)
}
fun excludeWildcardChildAbsolute(id: String) {
require(id.isNotBlank()) { "Wildcard exclusion id must not be blank." }
val permissionId = PermissionId.of(id.lowercase())
draft.wildcardExclusions.add(permissionId)
}
}
private fun removeSubtree(rootId: PermissionId) {
val prefix = "${rootId.value}."
val targets =
drafts.keys.filter { key ->
key.value == rootId.value || key.value.startsWith(prefix)
}.toSet()
if (targets.isEmpty()) return
targets.forEach { drafts.remove(it) }
drafts.values.forEach { draft ->
val iterator = draft.children.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
if (entry.key in targets) {
iterator.remove()
}
}
}
}
private fun renameSubtree(
oldRoot: PermissionId,
newRoot: PermissionId,
) {
if (oldRoot == newRoot) return
val prefix = "${oldRoot.value}."
val affected =
drafts.keys.filter { key ->
key.value == oldRoot.value || key.value.startsWith(prefix)
}
if (affected.isEmpty()) return
val affectedSet = affected.toSet()
val mapping = linkedMapOf<PermissionId, PermissionId>()
affected.forEach { oldId ->
val suffix = oldId.value.removePrefix(oldRoot.value)
val newValue = newRoot.value + suffix
val newId = PermissionId.of(newValue)
if (!affectedSet.contains(newId) && drafts.containsKey(newId)) {
error("Cannot rename '${oldRoot.value}' to '${newRoot.value}' because '$newValue' already exists.")
}
mapping[oldId] = newId
}
mapping.forEach { (oldId, newId) ->
val draft = drafts.remove(oldId) ?: return@forEach
val newDraft =
PermissionNodeDraft(
id = newId,
description = draft.description,
defaultValue = draft.defaultValue,
children = draft.children.toMutableMap(),
wildcard = draft.wildcard,
registration = draft.registration,
wildcardExclusions = draft.wildcardExclusions.toMutableSet(),
)
drafts[newId] = newDraft
}
drafts.values.forEach { draft ->
val pending = mutableListOf<Pair<PermissionId, Boolean>>()
val iterator = draft.children.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
val replacement = mapping[entry.key]
if (replacement != null) {
iterator.remove()
pending += replacement to entry.value
}
}
pending.forEach { (id, value) -> draft.children[id] = value }
}
}
companion object {
fun create(namespace: String): MutablePermissionTree =
MutablePermissionTree(namespace.trim().lowercase(), linkedMapOf())
fun create(namespace: String): MutablePermissionTree = MutablePermissionTree(namespace.trim().lowercase(), linkedMapOf())
fun from(tree: PermissionTree): MutablePermissionTree =
MutablePermissionTree(
namespace = tree.namespace,
drafts = tree.nodes.mapValues { PermissionNodeDraft.from(it.value) }.toMutableMap()
drafts = tree.nodes.mapValues { PermissionNodeDraft.from(it.value) }.toMutableMap(),
)
}
}
@@ -0,0 +1,10 @@
package net.hareworks.permits_lib.domain
/**
* Declares whether a DSL node should materialize as an actual Bukkit permission or behave as a
* purely structural placeholder (still participates in relationships/wildcards).
*/
enum class NodeRegistration(val registersPermission: Boolean) {
PERMISSION(true),
STRUCTURAL(false),
}
@@ -13,8 +13,9 @@ data class PermissionNode(
val description: String? = null,
val defaultValue: PermissionDefault = PermissionDefault.FALSE,
val children: Map<PermissionId, Boolean> = emptyMap(),
val tags: Set<String> = emptySet(),
val wildcard: Boolean = true
val wildcard: Boolean = false,
val registration: NodeRegistration = NodeRegistration.PERMISSION,
val wildcardExclusions: Set<PermissionId> = emptySet(),
) {
init {
require(children.keys.none { it == id }) { "Permission node cannot be a child of itself." }
@@ -7,8 +7,9 @@ internal data class PermissionNodeDraft(
var description: String? = null,
var defaultValue: PermissionDefault = PermissionDefault.FALSE,
val children: MutableMap<PermissionId, Boolean> = linkedMapOf(),
val tags: MutableSet<String> = linkedSetOf(),
var wildcard: Boolean = true
var wildcard: Boolean = false,
var registration: NodeRegistration = NodeRegistration.PERMISSION,
val wildcardExclusions: MutableSet<PermissionId> = linkedSetOf(),
) {
fun toNode(): PermissionNode =
PermissionNode(
@@ -16,8 +17,9 @@ internal data class PermissionNodeDraft(
description = description,
defaultValue = defaultValue,
children = children.toMap(),
tags = tags.toSet(),
wildcard = wildcard
wildcard = wildcard,
registration = registration,
wildcardExclusions = wildcardExclusions.toSet(),
)
companion object {
@@ -27,8 +29,9 @@ internal data class PermissionNodeDraft(
description = node.description,
defaultValue = node.defaultValue,
children = node.children.toMutableMap(),
tags = node.tags.toMutableSet(),
wildcard = node.wildcard
wildcard = node.wildcard,
registration = node.registration,
wildcardExclusions = node.wildcardExclusions.toMutableSet(),
)
}
}
@@ -5,7 +5,7 @@ package net.hareworks.permits_lib.domain
*/
class PermissionTree internal constructor(
val namespace: String,
internal val nodes: Map<PermissionId, PermissionNode>
internal val nodes: Map<PermissionId, PermissionNode>,
) {
init {
require(namespace.isNotBlank()) { "Permission namespace must not be blank." }
@@ -15,12 +15,15 @@ class PermissionTree internal constructor(
operator fun get(id: PermissionId): PermissionNode? = nodes[id]
fun toSnapshot(): TreeSnapshot = TreeSnapshot(nodes)
fun toSnapshot(): TreeSnapshot = TreeSnapshot(nodes.filterValues { it.registration.registersPermission })
companion object {
fun empty(namespace: String): PermissionTree = PermissionTree(namespace, emptyMap())
fun from(namespace: String, rawNodes: Map<PermissionId, PermissionNode>): PermissionTree {
fun from(
namespace: String,
rawNodes: Map<PermissionId, PermissionNode>,
): PermissionTree {
val augmented = WildcardAugmentor.apply(rawNodes)
PermissionTreeValidator.validate(augmented)
return PermissionTree(namespace, augmented)
@@ -3,7 +3,7 @@ package net.hareworks.permits_lib.domain
data class TreeDiff(
val added: List<PermissionNode>,
val removed: List<PermissionNode>,
val updated: List<UpdatedNode>
val updated: List<UpdatedNode>,
) {
val hasChanges: Boolean
get() = added.isNotEmpty() || removed.isNotEmpty() || updated.isNotEmpty()
@@ -1,7 +1,10 @@
package net.hareworks.permits_lib.domain
object TreeDiffer {
fun diff(previous: TreeSnapshot?, next: TreeSnapshot): TreeDiff {
fun diff(
previous: TreeSnapshot?,
next: TreeSnapshot,
): TreeDiff {
val prevNodes = previous?.nodes.orEmpty()
val nextNodes = next.nodes
@@ -23,7 +26,7 @@ object TreeDiffer {
return TreeDiff(
added = added.sortedBy { it.id.value },
removed = removed.sortedBy { it.id.value },
updated = updated.sortedBy { it.after.id.value }
updated = updated.sortedBy { it.after.id.value },
)
}
}
@@ -6,7 +6,7 @@ import java.security.MessageDigest
* Snapshot of a tree at a specific point in time. Holds a deterministic digest useful for caching.
*/
class TreeSnapshot internal constructor(
internal val nodes: Map<PermissionId, PermissionNode>
internal val nodes: Map<PermissionId, PermissionNode>,
) {
val digest: String = computeDigest(nodes)
@@ -25,10 +25,8 @@ class TreeSnapshot internal constructor(
digest.update(childId.value.toByteArray())
digest.update(if (flag) 1 else 0)
}
node.tags.sorted().forEach { tag ->
digest.update(tag.toByteArray())
}
digest.update(if (node.wildcard) 1 else 0)
digest.update(node.registration.name.toByteArray())
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
@@ -1,7 +1,5 @@
package net.hareworks.permits_lib.domain
import org.bukkit.permissions.PermissionDefault
internal object WildcardAugmentor {
fun apply(nodes: Map<PermissionId, PermissionNode>): Map<PermissionId, PermissionNode> {
if (nodes.isEmpty()) return nodes
@@ -11,24 +9,23 @@ internal object WildcardAugmentor {
if (!node.wildcard) return@forEach
if (node.id.value.endsWith(".*")) return@forEach
val wildcardId = parentWildcardId(node.id) ?: return@forEach
val existing = result[wildcardId]
val updatedChildren = (existing?.children ?: emptyMap()).toMutableMap()
val alreadyPresent = updatedChildren[node.id] == true
if (!alreadyPresent) {
updatedChildren[node.id] = true
}
val wildcardId = PermissionId.of("${node.id.value}.*")
val updatedChildren =
node.children
.filterKeys { childId -> childId !in node.wildcardExclusions }
.toMutableMap()
val existing = result[wildcardId]
if (existing == null) {
result[wildcardId] = PermissionNode(
id = wildcardId,
description = "Wildcard for ${wildcardId.value}",
defaultValue = node.defaultValue,
children = updatedChildren,
tags = setOf("wildcard"),
wildcard = false
)
} else if (!alreadyPresent) {
result[wildcardId] =
PermissionNode(
id = wildcardId,
description = "Wildcard for ${node.id.value}",
defaultValue = node.defaultValue,
children = updatedChildren,
wildcard = false,
)
} else {
result[wildcardId] = existing.copy(children = updatedChildren)
}
}
@@ -1,5 +1,6 @@
package net.hareworks.permits_lib.dsl
import net.hareworks.permits_lib.domain.NodeRegistration
import net.hareworks.permits_lib.domain.PermissionId
import net.hareworks.permits_lib.domain.PermissionNodeDraft
import org.bukkit.permissions.PermissionDefault
@@ -7,7 +8,7 @@ import org.bukkit.permissions.PermissionDefault
@PermissionDsl
class PermissionNodeBuilder internal constructor(
private val treeBuilder: PermissionTreeBuilder,
private val draft: PermissionNodeDraft
private val draft: PermissionNodeDraft,
) {
var description: String?
get() = draft.description
@@ -27,21 +28,38 @@ class PermissionNodeBuilder internal constructor(
draft.wildcard = value
}
fun tag(value: String) {
if (value.isNotBlank()) {
draft.tags += value.trim()
}
fun wildcard(block: WildcardDsl.() -> Unit) {
wildcard = true
WildcardDsl(draft).apply(block)
}
fun child(id: String, value: Boolean = true) {
var registration: NodeRegistration
get() = draft.registration
set(value) {
draft.registration = value
}
fun child(
id: String,
value: Boolean = true,
) {
treeBuilder.childRelative(draft, id, value)
}
fun child(id: PermissionId, value: Boolean = true) {
fun child(
id: PermissionId,
value: Boolean = true,
) {
treeBuilder.childAbsolute(draft, id.value, value)
}
fun childAbsolute(id: String, value: Boolean = true) {
/**
* Links to a fully-qualified permission id. The provided [id] must already include its namespace.
*/
fun childAbsolute(
id: String,
value: Boolean = true,
) {
treeBuilder.childAbsolute(draft, id, value)
}
@@ -49,12 +67,32 @@ class PermissionNodeBuilder internal constructor(
* Declares a nested node whose id is derived from the current node:
*
* ```
* node("command") {
* node("reload") { ... } // -> namespace.command.reload
* node("command", NodeRegistration.STRUCTURAL) {
* node("reload", NodeRegistration.PERMISSION) { ... } // -> namespace.command.reload
* }
* ```
*/
fun node(id: String, block: PermissionNodeBuilder.() -> Unit = {}) {
treeBuilder.nestedNode(draft, id, block)
fun node(
id: String,
registration: NodeRegistration,
block: PermissionNodeBuilder.() -> Unit = {},
) {
treeBuilder.nestedNode(draft, id, registration, block)
}
}
class WildcardDsl internal constructor(
private val draft: PermissionNodeDraft,
) {
fun exclude(vararg segments: String) {
val normalized =
segments
.flatMap { it.split('.') }
.map { it.trim().lowercase() }
.filter { it.isNotEmpty() }
if (normalized.isEmpty()) return
val suffix = normalized.joinToString(".")
val permissionId = PermissionId.of("${draft.id.value}.$suffix")
draft.wildcardExclusions.add(permissionId)
}
}
@@ -1,70 +1,80 @@
package net.hareworks.permits_lib.dsl
import net.hareworks.permits_lib.domain.NodeRegistration
import net.hareworks.permits_lib.domain.PermissionId
import net.hareworks.permits_lib.domain.PermissionNodeDraft
import net.hareworks.permits_lib.domain.PermissionTree
@PermissionDsl
class PermissionTreeBuilder internal constructor(
private val namespace: String
private val namespace: String,
) {
private val drafts = linkedMapOf<PermissionId, PermissionNodeDraft>()
fun node(id: String, block: PermissionNodeBuilder.() -> Unit = {}) {
val permissionId = PermissionId.of(qualify(id))
fun node(
id: String,
registration: NodeRegistration,
block: PermissionNodeBuilder.() -> Unit = {},
) {
require(id.isNotBlank()) { "Node id must not be blank." }
val permissionId = PermissionId.of("$namespace.${id.lowercase()}")
val draft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) }
draft.registration = registration
PermissionNodeBuilder(this, draft).apply(block)
}
internal fun qualify(id: String): String =
if (id.startsWith(namespace)) id else "$namespace.$id"
internal fun child(
parent: PermissionNodeDraft,
id: String,
value: Boolean,
relative: Boolean
relative: Boolean,
) {
val permissionId = PermissionId.of(if (relative) qualifyRelative(parent.id, id) else qualify(id))
val target =
if (relative) {
require(id.isNotBlank()) { "Child id must not be blank." }
"${parent.id.value}.${id.lowercase()}"
} else {
normalizeAbsolute(id)
}
val permissionId = PermissionId.of(target)
parent.children[permissionId] = value
}
internal fun childRelative(
parent: PermissionNodeDraft,
id: String,
value: Boolean
value: Boolean,
) = child(parent, id, value, relative = true)
internal fun childAbsolute(
parent: PermissionNodeDraft,
id: String,
value: Boolean
value: Boolean,
) = child(parent, id, value, relative = false)
internal fun nestedNode(
parent: PermissionNodeDraft,
id: String,
block: PermissionNodeBuilder.() -> Unit
registration: NodeRegistration,
block: PermissionNodeBuilder.() -> Unit,
) {
val permissionId = PermissionId.of(qualifyRelative(parent.id, id))
parent.children[permissionId] = true
val draft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) }
require(id.isNotBlank()) { "Nested node id must not be blank." }
val composedId = PermissionId.of("${parent.id.value}.${id.lowercase()}")
parent.children[composedId] = true
val draft = drafts.getOrPut(composedId) { PermissionNodeDraft(composedId) }
draft.registration = registration
PermissionNodeBuilder(this, draft).apply(block)
}
fun build(): PermissionTree =
PermissionTree.from(namespace, drafts.mapValues { it.value.toNode() })
fun build(): PermissionTree = PermissionTree.from(namespace, drafts.mapValues { it.value.toNode() })
private fun qualifyRelative(parent: PermissionId, childSegment: String): String {
val normalized = childSegment.trim().lowercase().trimStart('.')
require(normalized.isNotEmpty()) { "Child id must not be blank." }
return if (normalized.startsWith(namespace)) {
normalized
} else {
"${parent.value}.$normalized"
}
private fun normalizeAbsolute(id: String): String {
require(id.isNotBlank()) { "Absolute permission id must not be blank." }
return id.lowercase()
}
}
fun permissionTree(namespace: String, block: PermissionTreeBuilder.() -> Unit): PermissionTree =
PermissionTreeBuilder(namespace.trim().lowercase()).apply(block).build()
fun permissionTree(
namespace: String,
block: PermissionTreeBuilder.() -> Unit,
): PermissionTree = PermissionTreeBuilder(namespace.trim().lowercase()).apply(block).build()
@@ -1,11 +1,30 @@
package net.hareworks.permits_lib.util
import org.bukkit.Bukkit
import org.bukkit.entity.Player
import org.bukkit.permissions.Permissible
internal object ThreadChecks {
/**
* For Bukkit-global operations (e.g. PluginManager permission registration).
* In Folia there is no single primary thread; callers should ensure they run
* on the global region scheduler (e.g. during onEnable / onDisable).
*/
fun ensurePrimaryThread(action: String) {
check(Bukkit.isPrimaryThread()) {
"$action must be invoked from the primary server thread."
// no-op for global ops: Folia has no single primary thread.
// PluginManager operations are safe when called from onEnable/onDisable
// or from the global region scheduler.
}
/**
* For player-bound operations (e.g. PermissionAttachment mutation).
* Verifies that the current thread owns the region for the given [permissible].
* Non-player permissibles are skipped since they have no region owner.
*/
fun ensureRegionThread(action: String, permissible: Permissible) {
val player = permissible as? Player ?: return
check(Bukkit.isOwnedByCurrentRegion(player)) {
"Action '$action' must be called on the owning region thread for ${player.name}"
}
}
}