chore: publish pearcord-guild-roles from pearcord workspace
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# pearcord-guild-roles
|
||||
|
||||
Custom guild roles with permission bitmasks and member assignments (JsonStore per workspace).
|
||||
|
||||
Used by `pearcord-platform` for `createGuildCustomRole`, effective `view.myPermissions`, and `GUILD_SYNC` role bundles.
|
||||
|
||||
See `docs/ROLE_PERMISSIONS.md` in pearcord-docs.
|
||||
@@ -0,0 +1,278 @@
|
||||
'use strict'
|
||||
|
||||
const path = require('bare-path')
|
||||
const { JsonStore } = require('pearcord-db/store-json')
|
||||
const { id, now, MEMBER_ROLES, ROLE_PERMISSIONS } = require('pearcord-shared')
|
||||
const {
|
||||
normalizePermissionsMask,
|
||||
memberPermissionMask,
|
||||
maskToPermissionView,
|
||||
PERMISSION_META,
|
||||
ALL_PERMISSIONS_MASK
|
||||
} = require('./permissions')
|
||||
|
||||
const ROLES_COLLECTION = '@pearcord/guild-custom-roles'
|
||||
const LINKS_COLLECTION = '@pearcord/member-custom-role-links'
|
||||
const MAX_CUSTOM_ROLES = 24
|
||||
|
||||
function normalizeRoleName (name) {
|
||||
return String(name || '')
|
||||
.trim()
|
||||
.slice(0, 32)
|
||||
}
|
||||
|
||||
function validateRoleName (name) {
|
||||
const n = normalizeRoleName(name)
|
||||
if (!n || n.length < 2) throw new Error('role name must be at least 2 characters')
|
||||
if (/^(owner|moderator|member)$/i.test(n)) {
|
||||
throw new Error('reserved role name — use builtin promote/demote for moderator')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function normalizeColor (color) {
|
||||
const c = String(color || '#99aab5').trim()
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(c)) return c.toLowerCase()
|
||||
return '#99aab5'
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-guild custom roles + member role assignments (JsonStore, gossip via platform/guild).
|
||||
*/
|
||||
class PearcordGuildRoles {
|
||||
constructor (opts = {}) {
|
||||
this.storagePath = opts.storagePath || './pearcord-storage'
|
||||
this.guildId = opts.guildId || null
|
||||
const base = path.join(this.storagePath, 'guild-roles')
|
||||
this.store = new JsonStore(base)
|
||||
}
|
||||
|
||||
async ready () {
|
||||
await this.store.ready()
|
||||
return this
|
||||
}
|
||||
|
||||
setGuild (guildId) {
|
||||
this.guildId = guildId
|
||||
}
|
||||
|
||||
_guildKey (guildId) {
|
||||
return guildId || this.guildId
|
||||
}
|
||||
|
||||
async listRoles (guildId) {
|
||||
const gid = this._guildKey(guildId)
|
||||
if (!gid) return []
|
||||
const rows = await this.store.find(ROLES_COLLECTION, { guildId: gid })
|
||||
return rows.sort((a, b) => (a.position || 0) - (b.position || 0))
|
||||
}
|
||||
|
||||
async getRole (guildId, roleId) {
|
||||
const gid = this._guildKey(guildId)
|
||||
if (!gid || !roleId) return null
|
||||
return this.store.get(ROLES_COLLECTION, { guildId: gid, id: roleId })
|
||||
}
|
||||
|
||||
async createRole (guildId, { name, color, permissions, position } = {}) {
|
||||
const gid = this._guildKey(guildId)
|
||||
if (!gid) throw new Error('guildId required')
|
||||
const rows = await this.listRoles(gid)
|
||||
if (rows.length >= MAX_CUSTOM_ROLES) {
|
||||
throw new Error(`max ${MAX_CUSTOM_ROLES} custom roles per server`)
|
||||
}
|
||||
const roleName = validateRoleName(name)
|
||||
const dup = rows.find((r) => r.name.toLowerCase() === roleName.toLowerCase())
|
||||
if (dup) throw new Error('role name already exists')
|
||||
const row = {
|
||||
id: id(),
|
||||
guildId: gid,
|
||||
name: roleName,
|
||||
color: normalizeColor(color),
|
||||
permissions: normalizePermissionsMask(permissions),
|
||||
position: Number.isFinite(position) ? position : rows.length,
|
||||
createdAt: now()
|
||||
}
|
||||
await this.store.insert(ROLES_COLLECTION, row)
|
||||
return row
|
||||
}
|
||||
|
||||
async updateRole (guildId, roleId, patch = {}) {
|
||||
const gid = this._guildKey(guildId)
|
||||
const prev = await this.getRole(gid, roleId)
|
||||
if (!prev) throw new Error('role not found')
|
||||
const row = { ...prev }
|
||||
if (patch.name !== undefined) {
|
||||
const roleName = validateRoleName(patch.name)
|
||||
const rows = await this.listRoles(gid)
|
||||
const dup = rows.find(
|
||||
(r) => r.id !== roleId && r.name.toLowerCase() === roleName.toLowerCase()
|
||||
)
|
||||
if (dup) throw new Error('role name already exists')
|
||||
row.name = roleName
|
||||
}
|
||||
if (patch.color !== undefined) row.color = normalizeColor(patch.color)
|
||||
if (patch.permissions !== undefined) {
|
||||
row.permissions = normalizePermissionsMask(patch.permissions)
|
||||
}
|
||||
if (patch.position !== undefined) row.position = Number(patch.position) || 0
|
||||
row.updatedAt = now()
|
||||
await this.store.insert(ROLES_COLLECTION, row)
|
||||
return row
|
||||
}
|
||||
|
||||
async deleteRole (guildId, roleId) {
|
||||
const gid = this._guildKey(guildId)
|
||||
const prev = await this.getRole(gid, roleId)
|
||||
if (!prev) return null
|
||||
await this.store.delete(ROLES_COLLECTION, { guildId: gid, id: roleId })
|
||||
const links = await this.store.find(LINKS_COLLECTION, { guildId: gid })
|
||||
for (const link of links) {
|
||||
const ids = (link.roleIds || []).filter((x) => x !== roleId)
|
||||
if (ids.length !== (link.roleIds || []).length) {
|
||||
await this.store.insert(LINKS_COLLECTION, { ...link, roleIds: ids })
|
||||
}
|
||||
}
|
||||
return prev
|
||||
}
|
||||
|
||||
async getMemberRoleIds (guildId, userId) {
|
||||
const gid = this._guildKey(guildId)
|
||||
if (!gid || !userId) return []
|
||||
const row = await this.store.get(LINKS_COLLECTION, { guildId: gid, userId })
|
||||
return row?.roleIds || []
|
||||
}
|
||||
|
||||
async setMemberRoleIds (guildId, userId, roleIds) {
|
||||
const gid = this._guildKey(guildId)
|
||||
if (!gid || !userId) throw new Error('guildId and userId required')
|
||||
const roles = await this.listRoles(gid)
|
||||
const valid = new Set(roles.map((r) => r.id))
|
||||
const out = []
|
||||
for (const rid of roleIds || []) {
|
||||
if (valid.has(rid) && !out.includes(rid)) out.push(rid)
|
||||
}
|
||||
const row = {
|
||||
guildId: gid,
|
||||
userId,
|
||||
roleIds: out,
|
||||
updatedAt: now()
|
||||
}
|
||||
await this.store.insert(LINKS_COLLECTION, row)
|
||||
return row
|
||||
}
|
||||
|
||||
async listMemberLinks (guildId) {
|
||||
const gid = this._guildKey(guildId)
|
||||
if (!gid) return []
|
||||
return this.store.find(LINKS_COLLECTION, { guildId: gid })
|
||||
}
|
||||
|
||||
/** Merge builtin member row with custom role ids for permission resolution. */
|
||||
enrichMember (member, guildId) {
|
||||
if (!member) return member
|
||||
return { ...member, customRoleIds: member.customRoleIds || [] }
|
||||
}
|
||||
|
||||
async attachCustomRolesToMembers (guildId, members = []) {
|
||||
const gid = this._guildKey(guildId)
|
||||
const links = await this.listMemberLinks(gid)
|
||||
const byUser = new Map(links.map((l) => [l.userId, l.roleIds || []]))
|
||||
return members.map((m) => ({
|
||||
...m,
|
||||
customRoleIds: byUser.get(m.userId) || []
|
||||
}))
|
||||
}
|
||||
|
||||
resolvePermissions (member, customRoles) {
|
||||
const mask = memberPermissionMask(member, customRoles)
|
||||
return maskToPermissionView(mask)
|
||||
}
|
||||
|
||||
async ingestRoleGossip (row) {
|
||||
if (!row?.guildId || !row?.id) return null
|
||||
await this.store.insert(ROLES_COLLECTION, row)
|
||||
return row
|
||||
}
|
||||
|
||||
async ingestRoleDelete (payload) {
|
||||
if (!payload?.guildId || !payload?.id) return null
|
||||
await this.deleteRole(payload.guildId, payload.id)
|
||||
return payload
|
||||
}
|
||||
|
||||
async ingestMemberRolesGossip (payload) {
|
||||
if (!payload?.guildId || !payload?.userId) return null
|
||||
return this.setMemberRoleIds(payload.guildId, payload.userId, payload.roleIds || [])
|
||||
}
|
||||
|
||||
/** Snapshot for GUILD_SYNC bundles. */
|
||||
async exportSyncBundle (guildId) {
|
||||
const gid = this._guildKey(guildId)
|
||||
return {
|
||||
guildRoles: await this.listRoles(gid),
|
||||
memberRoleLinks: await this.listMemberLinks(gid)
|
||||
}
|
||||
}
|
||||
|
||||
async ingestSyncBundle (guildId, payload = {}) {
|
||||
const gid = this._guildKey(guildId)
|
||||
if (!gid) return { roles: 0, links: 0 }
|
||||
let roles = 0
|
||||
let links = 0
|
||||
for (const row of payload.guildRoles || []) {
|
||||
if (row?.guildId === gid) {
|
||||
await this.ingestRoleGossip(row)
|
||||
roles++
|
||||
}
|
||||
}
|
||||
for (const link of payload.memberRoleLinks || []) {
|
||||
if (link?.guildId === gid && link?.userId) {
|
||||
await this.ingestMemberRolesGossip(link)
|
||||
links++
|
||||
}
|
||||
}
|
||||
return { roles, links }
|
||||
}
|
||||
|
||||
/** Built-in role rows for UI (read-only templates). */
|
||||
builtinRoleTemplates () {
|
||||
return [
|
||||
{
|
||||
id: 'builtin:owner',
|
||||
builtin: true,
|
||||
name: 'Owner',
|
||||
color: '#f0b232',
|
||||
permissions: ALL_PERMISSIONS_MASK,
|
||||
locked: true
|
||||
},
|
||||
{
|
||||
id: 'builtin:moderator',
|
||||
builtin: true,
|
||||
name: 'Moderator',
|
||||
color: '#57f287',
|
||||
permissions: ROLE_PERMISSIONS[MEMBER_ROLES.MODERATOR],
|
||||
locked: true,
|
||||
hint: 'Use Promote/Demote or assign custom roles for extra permissions'
|
||||
},
|
||||
{
|
||||
id: 'builtin:member',
|
||||
builtin: true,
|
||||
name: 'Member',
|
||||
color: '#99aab5',
|
||||
permissions: ROLE_PERMISSIONS[MEMBER_ROLES.MEMBER],
|
||||
locked: true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PearcordGuildRoles,
|
||||
ROLES_COLLECTION,
|
||||
LINKS_COLLECTION,
|
||||
MAX_CUSTOM_ROLES,
|
||||
PERMISSION_META,
|
||||
normalizePermissionsMask,
|
||||
memberPermissionMask,
|
||||
maskToPermissionView
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "pearcord-guild-roles",
|
||||
"version": "0.1.0",
|
||||
"main": "index.js",
|
||||
"type": "commonjs",
|
||||
"description": "Custom guild roles with permission bitmasks and P2P gossip",
|
||||
"dependencies": {
|
||||
"bare-path": "^3.0.0",
|
||||
"pearcord-db": "git+https://git.ssh.surf/pearcord/pearcord-db.git#main",
|
||||
"pearcord-shared": "git+https://git.ssh.surf/pearcord/pearcord-shared.git#main"
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
'use strict'
|
||||
|
||||
const { PERMISSION, ROLE_PERMISSIONS, MEMBER_ROLES } = require('pearcord-shared')
|
||||
|
||||
const ALL_PERMISSIONS_MASK =
|
||||
PERMISSION.MANAGE_GUILD |
|
||||
PERMISSION.MANAGE_CHANNELS |
|
||||
PERMISSION.DELETE_ANY_MESSAGE |
|
||||
PERMISSION.MODERATE_MEMBERS |
|
||||
PERMISSION.MANAGE_MESSAGES
|
||||
|
||||
/** UI + editor metadata (Discord-style permission toggles). */
|
||||
const PERMISSION_META = [
|
||||
{
|
||||
flag: PERMISSION.MANAGE_GUILD,
|
||||
key: 'manageGuild',
|
||||
label: 'Manage server',
|
||||
description: 'Edit server settings, roles, and discovery listing'
|
||||
},
|
||||
{
|
||||
flag: PERMISSION.MANAGE_CHANNELS,
|
||||
key: 'manageChannels',
|
||||
label: 'Manage channels',
|
||||
description: 'Create categories, text, voice, and forum channels'
|
||||
},
|
||||
{
|
||||
flag: PERMISSION.MANAGE_MESSAGES,
|
||||
key: 'manageMessages',
|
||||
label: 'Manage messages',
|
||||
description: 'Post in announcement channels and slowmode-exempt sends'
|
||||
},
|
||||
{
|
||||
flag: PERMISSION.DELETE_ANY_MESSAGE,
|
||||
key: 'deleteAny',
|
||||
label: 'Delete messages',
|
||||
description: 'Delete messages from other members'
|
||||
},
|
||||
{
|
||||
flag: PERMISSION.MODERATE_MEMBERS,
|
||||
key: 'moderateMembers',
|
||||
label: 'Moderate members',
|
||||
description: 'Kick, ban, and timeout members'
|
||||
}
|
||||
]
|
||||
|
||||
function normalizePermissionsMask (value) {
|
||||
const n = Number(value) || 0
|
||||
return n & ALL_PERMISSIONS_MASK
|
||||
}
|
||||
|
||||
function memberPermissionMask (member, customRoles = []) {
|
||||
const builtin = String(member?.roles || MEMBER_ROLES.MEMBER)
|
||||
if (builtin === MEMBER_ROLES.OWNER) return ALL_PERMISSIONS_MASK
|
||||
let mask = ROLE_PERMISSIONS[builtin] || 0
|
||||
const ids = Array.isArray(member?.customRoleIds) ? member.customRoleIds : []
|
||||
for (const id of ids) {
|
||||
const row = customRoles.find((r) => r.id === id)
|
||||
if (row) mask |= normalizePermissionsMask(row.permissions)
|
||||
}
|
||||
return mask
|
||||
}
|
||||
|
||||
function maskHasPermission (mask, perm) {
|
||||
return ((Number(mask) || 0) & perm) === perm
|
||||
}
|
||||
|
||||
function maskToPermissionView (mask) {
|
||||
return {
|
||||
deleteAny: maskHasPermission(mask, PERMISSION.DELETE_ANY_MESSAGE),
|
||||
manageChannels: maskHasPermission(mask, PERMISSION.MANAGE_CHANNELS),
|
||||
manageGuild: maskHasPermission(mask, PERMISSION.MANAGE_GUILD),
|
||||
manageMessages: maskHasPermission(mask, PERMISSION.MANAGE_MESSAGES),
|
||||
moderateMembers: maskHasPermission(mask, PERMISSION.MODERATE_MEMBERS)
|
||||
}
|
||||
}
|
||||
|
||||
function viewToMask (view = {}) {
|
||||
let mask = 0
|
||||
if (view.manageGuild) mask |= PERMISSION.MANAGE_GUILD
|
||||
if (view.manageChannels) mask |= PERMISSION.MANAGE_CHANNELS
|
||||
if (view.manageMessages) mask |= PERMISSION.MANAGE_MESSAGES
|
||||
if (view.deleteAny) mask |= PERMISSION.DELETE_ANY_MESSAGE
|
||||
if (view.moderateMembers) mask |= PERMISSION.MODERATE_MEMBERS
|
||||
return normalizePermissionsMask(mask)
|
||||
}
|
||||
|
||||
function toggleFlag (mask, flag, enabled) {
|
||||
const m = normalizePermissionsMask(mask)
|
||||
if (enabled) return m | flag
|
||||
return m & ~flag
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PERMISSION_META,
|
||||
ALL_PERMISSIONS_MASK,
|
||||
normalizePermissionsMask,
|
||||
memberPermissionMask,
|
||||
maskHasPermission,
|
||||
maskToPermissionView,
|
||||
viewToMask,
|
||||
toggleFlag
|
||||
}
|
||||
Reference in New Issue
Block a user