chore: publish pearcord-guild-roles from pearcord workspace

This commit is contained in:
Pearcord
2026-05-22 01:48:15 -04:00
commit 9a808b9357
4 changed files with 399 additions and 0 deletions
+7
View File
@@ -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.
+278
View File
@@ -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
}
+12
View File
@@ -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
View File
@@ -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
}