Search Apps Documentation Source Content File Folder Download Copy Actions Download

memba_dao_channels_v2.gno

40.35 Kb Β· 1336 lines
   1package memba_dao_channels_v2
   2
   3// memba_dao_channels β€” Social feed realm for MembaDAO.
   4//
   5// Provides Discord-like channels with threads, replies, and moderation.
   6// The Render() output MUST match the regex patterns in parser.ts (frontend).
   7//
   8// Render() contract (from parser.ts):
   9//
  10//   Home β€” Render(""):
  11//     # BoardName
  12//     description
  13//     ## Channels
  14//     - [#name](:_channel/name) πŸ“’ (N threads)
  15//
  16//   Channel β€” Render("channelName"):
  17//     # #channelName
  18//     ### [Title](:channel/id)
  19//     by g1addr... | N replies | block H
  20//
  21//   Thread β€” Render("channel/id"):
  22//     # Title
  23//     body
  24//     ---
  25//     *Posted by g1addr at block H*
  26//     ## Replies
  27//     **g1addr...** (block H)
  28//     reply body
  29//     ---
  30//
  31//   ACL β€” Render("__acl/channel"):
  32//     read:role1,role2
  33//     write:role1,role2,role3
  34//     type:text
  35//
  36// Security:
  37//   - All write operations require membership (AddMember by owner)
  38//   - PostThread/PostReply check caller's roles against channel WriteRoles
  39//   - CreateChannel/RemoveThread restricted to owner only
  40//   - FlagThread requires membership (prevents sybil flagging by non-members)
  41//   - EditThread/DeleteThread require membership + original author check
  42//
  43// Moderation: flag β†’ threshold (3) β†’ auto-hide β†’ DAO vote β†’ remove
  44
  45import (
  46	"chain"
  47	"chain/runtime"
  48	"chain/runtime/unsafe"
  49	"strconv"
  50	"strings"
  51
  52	"gno.land/p/nt/avl/v0"
  53	"gno.land/p/nt/ufmt/v0"
  54)
  55
  56// ── Constants ────────────────────────────────────────────────
  57
  58const (
  59	MaxPostLen        = 5000
  60	MaxTitleLen       = 200
  61	MaxChannels       = 20
  62	MaxThreadsPerChan = 500
  63	FlagThreshold     = 3 // flags before auto-hide
  64
  65	// AAA-1 B1 β€” render-DoS bounds for replies (mirrors MaxThreadsPerChan).
  66	// renderThread must never iterate the monotonic reply counter (post+delete
  67	// reply spam would inflate it without bound β†’ unbounded render scan β†’
  68	// thread permanently unrenderable past maxGasQuery). The live-reply index
  69	// (replyLive/replyLiveIDs) caps the live set, and renderThread paginates a
  70	// fixed window over it, so render cost is bounded regardless of churn.
  71	MaxRepliesPerThread = 500 // live (non-deleted) replies per thread
  72	ReplyPageSize       = 50  // replies rendered per page
  73)
  74
  75// ── Types ────────────────────────────────────────────────────
  76
  77type ChannelType string
  78
  79const (
  80	ChannelText          ChannelType = "text"
  81	ChannelAnnouncements ChannelType = "announcements"
  82	ChannelReadonly      ChannelType = "readonly"
  83)
  84
  85type Channel struct {
  86	Name        string
  87	Description string
  88	Type        ChannelType
  89	Archived    bool
  90	ReadRoles   []string // roles that can read
  91	WriteRoles  []string // roles that can write
  92}
  93
  94type Thread struct {
  95	ID        uint64
  96	Channel   string
  97	Title     string
  98	Body      string
  99	Author    address
 100	BlockH    int64
 101	Edited    bool
 102	EditedAt  int64
 103	Deleted   bool
 104	FlagCount int
 105	Hidden    bool
 106}
 107
 108type Reply struct {
 109	ID       uint64
 110	ThreadID uint64
 111	Channel  string
 112	Body     string
 113	Author   address
 114	BlockH   int64
 115	Edited   bool
 116}
 117
 118// ── State ────────────────────────────────────────────────────
 119
 120var (
 121	channels      *avl.Tree // name -> *Channel
 122	threads       *avl.Tree // "channel/id" -> *Thread
 123	replies       *avl.Tree // "channel/threadId/replyId" -> *Reply
 124	threadCount   *avl.Tree // channel -> uint64 (next thread ID, monotonic)
 125	threadLive    *avl.Tree // channel -> uint64 (currently live (non-deleted) threads)
 126	threadLiveIDs *avl.Tree // channel -> []uint64 (live thread IDs, ascending) β€” bounds renderChannel
 127	replyCount    *avl.Tree // "channel/threadId" -> uint64 (next reply ID, monotonic)
 128	replyLive     *avl.Tree // "channel/threadId" -> uint64 (currently live (non-deleted) replies)
 129	replyLiveIDs  *avl.Tree // "channel/threadId" -> []uint64 (live reply IDs, ascending) β€” bounds renderThread
 130	threadTomb    *avl.Tree // channel -> []uint64 (soft-deleted thread IDs awaiting hard-GC via SweepTombstones) β€” B2
 131	flags         *avl.Tree // "channel/threadId" -> *avl.Tree (flagger addr -> bool)
 132	channelOrder  []string  // ordered channel names
 133
 134	// Membership: address -> comma-separated roles (e.g., "admin,dev")
 135	members *avl.Tree
 136	paused  bool
 137	// Owner is set at package load time via OriginCaller() β€” this captures the
 138	// deployer address (samcrew-core-test1 multisig on testnet).
 139	owner = unsafe.OriginCaller()
 140)
 141
 142func init() {
 143	channels = avl.NewTree()
 144	threads = avl.NewTree()
 145	replies = avl.NewTree()
 146	threadCount = avl.NewTree()
 147	threadLive = avl.NewTree()
 148	threadLiveIDs = avl.NewTree()
 149	replyCount = avl.NewTree()
 150	replyLive = avl.NewTree()
 151	replyLiveIDs = avl.NewTree()
 152	threadTomb = avl.NewTree()
 153	flags = avl.NewTree()
 154	channelOrder = []string{}
 155	members = avl.NewTree()
 156	members.Set(owner.String(), "admin")
 157
 158	// Default channels matching MEMBA_DAO_CHANNELS in config.ts
 159	addChannel("general", "General discussion for all members", ChannelText,
 160		[]string{"admin", "dev", "ops", "member"}, []string{"admin", "dev", "ops", "member"})
 161	addChannel("announcements", "Official MembaDAO announcements β€” admin-write-only", ChannelAnnouncements,
 162		[]string{"admin", "dev", "ops", "member"}, []string{"admin"})
 163	addChannel("feature-requests", "Propose and discuss new features", ChannelText,
 164		[]string{"admin", "dev", "ops", "member"}, []string{"admin", "dev", "ops", "member"})
 165	addChannel("support", "Help and troubleshooting", ChannelText,
 166		[]string{"admin", "dev", "ops", "member"}, []string{"admin", "dev", "ops", "member"})
 167	addChannel("extensions", "Plugin and extension development", ChannelText,
 168		[]string{"admin", "dev", "ops", "member"}, []string{"admin", "dev", "member"})
 169	addChannel("partnerships", "Collaboration and partnership proposals", ChannelText,
 170		[]string{"admin", "dev", "ops", "member"}, []string{"admin", "dev", "ops", "member"})
 171}
 172
 173// ── Channel Management ───────────────────────────────────────
 174
 175func addChannel(name, description string, ctype ChannelType, readRoles, writeRoles []string) {
 176	ch := &Channel{
 177		Name:        name,
 178		Description: description,
 179		Type:        ctype,
 180		Archived:    false,
 181		ReadRoles:   readRoles,
 182		WriteRoles:  writeRoles,
 183	}
 184	channels.Set(name, ch)
 185	threadCount.Set(name, uint64(0))
 186	threadLive.Set(name, uint64(0))
 187	channelOrder = append(channelOrder, name)
 188}
 189
 190// ── Membership Management ───────────────────────────────────
 191
 192// AddMember adds an address with specified roles. Only the owner can call this.
 193// roles is a comma-separated list (e.g., "admin,dev" or "member").
 194func AddMember(cur realm, addr address, roles string) {
 195	assertCallerIsOwner()
 196	if addr == "" {
 197		panic("address cannot be empty")
 198	}
 199	if len(roles) == 0 {
 200		panic("roles cannot be empty")
 201	}
 202	if !isValidRoles(roles) {
 203		panic("invalid roles: must be comma-separated alphanumeric only")
 204	}
 205	members.Set(addr.String(), roles)
 206
 207	chain.Emit("MemberAdded",
 208		"address", addr.String(),
 209		"roles", roles,
 210	)
 211}
 212
 213// RemoveMember removes an address from the membership. Only the owner can call this.
 214func RemoveMember(cur realm, addr address) {
 215	assertCallerIsOwner()
 216	if addr == owner {
 217		panic("cannot remove owner")
 218	}
 219	if _, exists := members.Get(addr.String()); !exists {
 220		panic("address is not a member: " + addr.String())
 221	}
 222	members.Remove(addr.String())
 223
 224	chain.Emit("MemberRemoved", "address", addr.String())
 225}
 226
 227// UpdateMemberRoles updates the roles for an existing member. Only the owner can call this.
 228func UpdateMemberRoles(cur realm, addr address, newRoles string) {
 229	assertCallerIsOwner()
 230	if _, exists := members.Get(addr.String()); !exists {
 231		panic("address is not a member: " + addr.String())
 232	}
 233	if len(newRoles) == 0 {
 234		panic("roles cannot be empty")
 235	}
 236	if !isValidRoles(newRoles) {
 237		panic("invalid roles: must be comma-separated alphanumeric only")
 238	}
 239	members.Set(addr.String(), newRoles)
 240
 241	chain.Emit("MemberRolesUpdated", "address", addr.String(), "roles", newRoles)
 242}
 243
 244// TransferOwnership transfers realm ownership to a new address. Only the current owner can call this.
 245// The new owner gets "admin" added to their existing roles (not overwritten).
 246// The old owner is demoted from "admin" to "member".
 247func TransferOwnership(cur realm, newOwner address) {
 248	assertCallerIsOwner()
 249	if newOwner == "" {
 250		panic("address cannot be empty")
 251	}
 252	if newOwner == owner {
 253		panic("new owner is the same as current owner")
 254	}
 255	prevOwner := owner
 256	// Preserve existing roles for new owner, ensure they have admin
 257	existingRoles := GetMemberRoles(newOwner)
 258	if existingRoles == "" {
 259		members.Set(newOwner.String(), "admin")
 260	} else if !hasRole(newOwner, "admin") {
 261		members.Set(newOwner.String(), existingRoles+",admin")
 262	}
 263	// Remove "admin" from old owner's roles, keep other roles.
 264	// If no roles remain, set to "member".
 265	oldRoles := GetMemberRoles(owner)
 266	var kept []string
 267	for _, r := range strings.Split(oldRoles, ",") {
 268		r = strings.TrimSpace(r)
 269		if r != "admin" && r != "" {
 270			kept = append(kept, r)
 271		}
 272	}
 273	if len(kept) == 0 {
 274		members.Set(owner.String(), "member")
 275	} else {
 276		members.Set(owner.String(), strings.Join(kept, ","))
 277	}
 278	owner = newOwner
 279
 280	chain.Emit("OwnershipTransferred",
 281		"previousOwner", prevOwner.String(),
 282		"newOwner", newOwner.String(),
 283	)
 284}
 285
 286// SyncMembers allows the owner to batch-sync membership from the DAO.
 287// addresses and rolesList are comma-separated, with roles pipe-delimited per address.
 288// Example: SyncMembers("g1a,g1b", "admin,dev|member")
 289func SyncMembers(cur realm, addresses string, rolesList string) {
 290	assertCallerIsOwner()
 291	addrs := strings.Split(addresses, ",")
 292	roles := strings.Split(rolesList, "|")
 293	if len(addrs) != len(roles) {
 294		panic("addresses and roles count mismatch")
 295	}
 296	synced := 0
 297	for i, addr := range addrs {
 298		addr = strings.TrimSpace(addr)
 299		if addr == "" {
 300			continue
 301		}
 302		role := strings.TrimSpace(roles[i])
 303		if !isValidRoles(role) {
 304			panic("invalid roles in entry " + strconv.Itoa(i))
 305		}
 306		members.Set(addr, role)
 307		synced++
 308	}
 309
 310	chain.Emit("MembersSynced", "count", strconv.Itoa(synced))
 311}
 312
 313// PurgeNonMembers removes all members not in the provided comma-separated list.
 314// The owner is never purged.
 315func PurgeNonMembers(cur realm, keepAddresses string) {
 316	assertCallerIsOwner()
 317	keep := make(map[string]bool)
 318	for _, addr := range strings.Split(keepAddresses, ",") {
 319		keep[strings.TrimSpace(addr)] = true
 320	}
 321	keep[owner.String()] = true // owner is never purged
 322	var toRemove []string
 323	members.Iterate("", "", func(key string, _ interface{}) bool {
 324		if !keep[key] {
 325			toRemove = append(toRemove, key)
 326		}
 327		return false
 328	})
 329	for _, addr := range toRemove {
 330		members.Remove(addr)
 331	}
 332
 333	chain.Emit("MembersPurged", "count", strconv.Itoa(len(toRemove)))
 334}
 335
 336// IsMember returns whether an address is a member.
 337func IsMember(addr address) bool {
 338	_, exists := members.Get(addr.String())
 339	return exists
 340}
 341
 342// GetMemberRoles returns the roles for a member (comma-separated) or empty string.
 343func GetMemberRoles(addr address) string {
 344	val, exists := members.Get(addr.String())
 345	if !exists {
 346		return ""
 347	}
 348	return val.(string)
 349}
 350
 351// GetOwner returns the current realm owner address.
 352func GetOwner() address {
 353	return owner
 354}
 355
 356// ── Emergency Pause ────────────────────────────────────────
 357//
 358// Pause policy (AAA-1 B3 β€” one policy, enforced everywhere):
 359//   While paused, every member-facing content write is blocked via
 360//   assertNotPaused() β€” PostThread, PostReply, EditThread, DeleteThread/Reply,
 361//   FlagThread, RemoveThread/Reply, and SweepTombstones.
 362//
 363//   Owner-only governance (AddMember/RemoveMember/UpdateMemberRoles/SyncMembers/
 364//   PurgeNonMembers/TransferOwnership/CreateChannel) is intentionally NOT gated:
 365//   pause halts user activity during an incident, but the owner must stay able to
 366//   fix membership/ownership while paused. This realm holds no funds, so there is
 367//   no value-exit exemption to carve out.
 368
 369func assertNotPaused() {
 370	if paused {
 371		panic("realm is paused β€” emergency maintenance")
 372	}
 373}
 374
 375func PauseRealm(cur realm) {
 376	assertCallerIsOwner()
 377	paused = true
 378	chain.Emit("RealmPaused", "by", owner.String())
 379}
 380
 381func UnpauseRealm(cur realm) {
 382	assertCallerIsOwner()
 383	paused = false
 384	chain.Emit("RealmUnpaused", "by", owner.String())
 385}
 386
 387func IsPaused() bool { return paused }
 388
 389// ── Channel Management ───────────────────────────────────────
 390
 391// CreateChannel adds a new channel. Only the owner (admin) can create channels.
 392func CreateChannel(cur realm, name, description string, ctype string) {
 393	assertCallerIsOwner()
 394
 395	if len(channelOrder) >= MaxChannels {
 396		panic(ufmt.Sprintf("max channels reached: %d", MaxChannels))
 397	}
 398	if _, exists := channels.Get(name); exists {
 399		panic("channel already exists: " + name)
 400	}
 401	if !isValidChannelName(name) {
 402		panic("invalid channel name: must be 1-50 lowercase alphanumeric characters or hyphens, no leading underscore")
 403	}
 404
 405	ct := ChannelText
 406	switch ctype {
 407	case "announcements":
 408		ct = ChannelAnnouncements
 409	case "readonly":
 410		ct = ChannelReadonly
 411	}
 412
 413	addChannel(name, description, ct,
 414		[]string{"admin", "dev", "ops", "member"},
 415		[]string{"admin", "dev", "ops", "member"})
 416
 417	chain.Emit("ChannelCreated", "name", name, "type", string(ct))
 418}
 419
 420// ── Post Management ─────────────────────────────────────────
 421
 422// PostThread creates a new thread in a channel.
 423// Caller must be a member with a role listed in the channel's WriteRoles.
 424func PostThread(cur realm, channel, title, body string) uint64 {
 425	assertNotPaused()
 426	caller := unsafe.PreviousRealm().Address()
 427
 428	// Validate membership and channel write access
 429	ch := getChannel(channel)
 430	assertCallerHasWriteAccess(caller, ch)
 431	if ch.Archived {
 432		panic("channel is archived")
 433	}
 434	// Cap on LIVE (non-deleted) threads so deleted threads free up slots.
 435	if getLiveThreadCount(channel) >= MaxThreadsPerChan {
 436		panic(ufmt.Sprintf("channel live thread limit reached: %d β€” delete old threads to make room", MaxThreadsPerChan))
 437	}
 438	if len(title) == 0 || len(title) > MaxTitleLen {
 439		panic(ufmt.Sprintf("title must be 1-%d characters", MaxTitleLen))
 440	}
 441	if len(body) > MaxPostLen {
 442		panic(ufmt.Sprintf("body too long: %d/%d chars", len(body), MaxPostLen))
 443	}
 444
 445	// Get next ID
 446	nextID := getThreadCount(channel)
 447	key := channel + "/" + strconv.FormatUint(nextID, 10)
 448
 449	t := &Thread{
 450		ID:      nextID,
 451		Channel: channel,
 452		Title:   title,
 453		Body:    body,
 454		Author:  caller,
 455		BlockH:  runtime.ChainHeight(),
 456	}
 457	threads.Set(key, t)
 458	threadCount.Set(channel, nextID+1)
 459	threadLive.Set(channel, getLiveThreadCount(channel)+1)
 460	addLiveThreadID(channel, nextID)
 461	replyCount.Set(key, uint64(0))
 462
 463	chain.Emit("ThreadPosted",
 464		"channel", channel,
 465		"threadId", strconv.FormatUint(nextID, 10),
 466		"author", caller.String(),
 467	)
 468
 469	return nextID
 470}
 471
 472// PostReply adds a reply to a thread.
 473// Caller must be a member with a role listed in the channel's WriteRoles.
 474func PostReply(cur realm, channel string, threadID uint64, body string) {
 475	assertNotPaused()
 476	caller := unsafe.PreviousRealm().Address()
 477
 478	// Validate membership and channel write access
 479	ch := getChannel(channel)
 480	assertCallerHasWriteAccess(caller, ch)
 481
 482	threadKey := channel + "/" + strconv.FormatUint(threadID, 10)
 483	tval, texists := threads.Get(threadKey)
 484	if !texists {
 485		panic("thread not found")
 486	}
 487	thread := tval.(*Thread)
 488	if thread.Deleted {
 489		panic("cannot reply to a deleted thread")
 490	}
 491	if thread.Hidden {
 492		panic("cannot reply to a hidden thread")
 493	}
 494
 495	if len(body) == 0 || len(body) > MaxPostLen {
 496		panic(ufmt.Sprintf("reply must be 1-%d characters", MaxPostLen))
 497	}
 498
 499	// B1: bound the LIVE reply set so renderThread stays under the gas budget.
 500	// Deleting replies frees slots (mirrors MaxThreadsPerChan / getLiveThreadCount).
 501	if getLiveReplyCount(threadKey) >= MaxRepliesPerThread {
 502		panic(ufmt.Sprintf("thread reply limit reached: %d β€” older replies must be removed", MaxRepliesPerThread))
 503	}
 504
 505	nextReplyID := getReplyCount(threadKey)
 506	replyKey := threadKey + "/" + strconv.FormatUint(nextReplyID, 10)
 507
 508	r := &Reply{
 509		ID:       nextReplyID,
 510		ThreadID: threadID,
 511		Channel:  channel,
 512		Body:     body,
 513		Author:   caller,
 514		BlockH:   runtime.ChainHeight(),
 515	}
 516	replies.Set(replyKey, r)
 517	replyCount.Set(threadKey, nextReplyID+1)
 518	// Track the live reply so renderThread iterates only live IDs (bounded set),
 519	// never the monotonic counter.
 520	addLiveReplyID(threadKey, nextReplyID)
 521	replyLive.Set(threadKey, getLiveReplyCount(threadKey)+1)
 522
 523	chain.Emit("ReplyPosted",
 524		"channel", channel,
 525		"threadId", strconv.FormatUint(threadID, 10),
 526		"replyId", strconv.FormatUint(nextReplyID, 10),
 527		"author", caller.String(),
 528	)
 529}
 530
 531// dropReply hard-removes a reply: deletes the node (storage reclaimed β€” B2),
 532// drops it from the live index, and decrements the live count. Shared by
 533// DeleteReply and RemoveReply. renderThread only walks the live index, so a
 534// removed reply is never looked up β€” no tombstone is needed.
 535func dropReply(threadKey, replyKey string, replyID uint64) {
 536	replies.Remove(replyKey)
 537	removeLiveReplyID(threadKey, replyID)
 538	if live := getLiveReplyCount(threadKey); live > 0 {
 539		replyLive.Set(threadKey, live-1)
 540	}
 541}
 542
 543// DeleteReply lets the reply's author delete it. Caller must be a member and the
 544// original author. The reply node is hard-removed (B2 state-shrink) and a slot is
 545// freed under MaxRepliesPerThread (B1).
 546func DeleteReply(cur realm, channel string, threadID, replyID uint64) {
 547	assertNotPaused()
 548	caller := unsafe.PreviousRealm().Address()
 549	assertCallerIsMember(caller)
 550
 551	threadKey := channel + "/" + strconv.FormatUint(threadID, 10)
 552	replyKey := threadKey + "/" + strconv.FormatUint(replyID, 10)
 553	rval, exists := replies.Get(replyKey)
 554	if !exists {
 555		panic("reply not found")
 556	}
 557	if rval.(*Reply).Author != caller {
 558		panic("only the author can delete")
 559	}
 560
 561	dropReply(threadKey, replyKey, replyID)
 562
 563	chain.Emit("ReplyDeleted",
 564		"channel", channel,
 565		"threadId", strconv.FormatUint(threadID, 10),
 566		"replyId", strconv.FormatUint(replyID, 10),
 567		"author", caller.String(),
 568	)
 569}
 570
 571// RemoveReply permanently removes a reply (moderation β€” admin role only). Hard-
 572// removes the node like DeleteReply, bounding renderThread (B1) + reclaiming
 573// storage (B2).
 574func RemoveReply(cur realm, channel string, threadID, replyID uint64) {
 575	assertCallerIsAdminRole()
 576	caller := unsafe.PreviousRealm().Address()
 577
 578	threadKey := channel + "/" + strconv.FormatUint(threadID, 10)
 579	replyKey := threadKey + "/" + strconv.FormatUint(replyID, 10)
 580	if _, exists := replies.Get(replyKey); !exists {
 581		panic("reply not found")
 582	}
 583
 584	dropReply(threadKey, replyKey, replyID)
 585
 586	chain.Emit("ReplyRemoved",
 587		"channel", channel,
 588		"threadId", strconv.FormatUint(threadID, 10),
 589		"replyId", strconv.FormatUint(replyID, 10),
 590		"admin", caller.String(),
 591	)
 592}
 593
 594// EditThread allows the original author to edit their thread. Caller must be a member.
 595func EditThread(cur realm, channel string, threadID uint64, newBody string) {
 596	assertNotPaused()
 597	caller := unsafe.PreviousRealm().Address()
 598	assertCallerIsMember(caller)
 599
 600	threadKey := channel + "/" + strconv.FormatUint(threadID, 10)
 601	val, exists := threads.Get(threadKey)
 602	if !exists {
 603		panic("thread not found")
 604	}
 605
 606	t := val.(*Thread)
 607	if t.Author != caller {
 608		panic("only the author can edit")
 609	}
 610	if t.Deleted {
 611		panic("cannot edit a deleted thread")
 612	}
 613	if t.Hidden {
 614		panic("cannot edit a hidden thread")
 615	}
 616	if len(newBody) > MaxPostLen {
 617		panic("body too long")
 618	}
 619
 620	t.Body = newBody
 621	t.Edited = true
 622	t.EditedAt = runtime.ChainHeight()
 623	threads.Set(threadKey, t)
 624
 625	chain.Emit("ThreadEdited",
 626		"channel", channel,
 627		"threadId", strconv.FormatUint(threadID, 10),
 628		"author", caller.String(),
 629	)
 630}
 631
 632// DeleteThread soft-deletes a thread (marks as deleted). Caller must be a member and the original author.
 633func DeleteThread(cur realm, channel string, threadID uint64) {
 634	assertNotPaused()
 635	caller := unsafe.PreviousRealm().Address()
 636	assertCallerIsMember(caller)
 637
 638	threadKey := channel + "/" + strconv.FormatUint(threadID, 10)
 639	val, exists := threads.Get(threadKey)
 640	if !exists {
 641		panic("thread not found")
 642	}
 643
 644	t := val.(*Thread)
 645	if t.Author != caller {
 646		panic("only the author can delete")
 647	}
 648	if t.Deleted {
 649		panic("thread already deleted")
 650	}
 651
 652	t.Deleted = true
 653	t.Title = "[Deleted]"
 654	t.Body = ""
 655	threads.Set(threadKey, t)
 656	// Free a slot for new threads
 657	live := getLiveThreadCount(channel)
 658	if live > 0 {
 659		threadLive.Set(channel, live-1)
 660	}
 661	removeLiveThreadID(channel, threadID)
 662	enqueueThreadTomb(channel, threadID) // B2: queue for hard-GC via SweepTombstones
 663
 664	chain.Emit("ThreadDeleted",
 665		"channel", channel,
 666		"threadId", strconv.FormatUint(threadID, 10),
 667		"author", caller.String(),
 668	)
 669}
 670
 671// ── Moderation ──────────────────────────────────────────────
 672
 673// FlagThread flags a thread for moderation review.
 674// After FlagThreshold flags, the thread is auto-hidden.
 675// Caller must be a member (any role) to flag content.
 676func FlagThread(cur realm, channel string, threadID uint64) {
 677	assertNotPaused()
 678	caller := unsafe.PreviousRealm().Address()
 679	assertCallerIsMember(caller)
 680	threadKey := channel + "/" + strconv.FormatUint(threadID, 10)
 681
 682	val, exists := threads.Get(threadKey)
 683	if !exists {
 684		panic("thread not found")
 685	}
 686
 687	t := val.(*Thread)
 688	if t.Deleted {
 689		panic("cannot flag a deleted thread")
 690	}
 691	if t.Hidden {
 692		panic("thread is already hidden")
 693	}
 694
 695	// Track unique flaggers
 696	var flagTree *avl.Tree
 697	if fval, fexists := flags.Get(threadKey); fexists {
 698		flagTree = fval.(*avl.Tree)
 699	} else {
 700		flagTree = avl.NewTree()
 701	}
 702
 703	if _, already := flagTree.Get(caller.String()); already {
 704		panic("already flagged")
 705	}
 706	flagTree.Set(caller.String(), true)
 707	flags.Set(threadKey, flagTree)
 708
 709	// Update thread flag count and auto-hide.
 710	// Dynamic threshold: max(FlagThreshold, 5% of members), scales with DAO size.
 711	t.FlagCount = flagTree.Size()
 712	dynamicThreshold := FlagThreshold
 713	fivePct := members.Size() / 20
 714	if fivePct > dynamicThreshold {
 715		dynamicThreshold = fivePct
 716	}
 717	wasHidden := t.Hidden
 718	if t.FlagCount >= dynamicThreshold {
 719		t.Hidden = true
 720	}
 721	threads.Set(threadKey, t)
 722
 723	chain.Emit("ThreadFlagged",
 724		"channel", channel,
 725		"threadId", strconv.FormatUint(threadID, 10),
 726		"flagger", caller.String(),
 727		"flagCount", strconv.Itoa(t.FlagCount),
 728	)
 729	if !wasHidden && t.Hidden {
 730		chain.Emit("ThreadAutoHidden",
 731			"channel", channel,
 732			"threadId", strconv.FormatUint(threadID, 10),
 733		)
 734	}
 735}
 736
 737// UnhideThread allows the owner or an admin to clear flags and un-hide a thread.
 738func UnhideThread(cur realm, channel string, threadID uint64) {
 739	assertCallerIsAdminRole()
 740	caller := unsafe.PreviousRealm().Address()
 741	threadKey := channel + "/" + strconv.FormatUint(threadID, 10)
 742
 743	val, exists := threads.Get(threadKey)
 744	if !exists {
 745		panic("thread not found")
 746	}
 747	t := val.(*Thread)
 748	t.Hidden = false
 749	t.FlagCount = 0
 750	threads.Set(threadKey, t)
 751	// Clear flag tree
 752	flags.Remove(threadKey)
 753
 754	chain.Emit("ThreadUnhidden",
 755		"channel", channel,
 756		"threadId", strconv.FormatUint(threadID, 10),
 757		"admin", caller.String(),
 758	)
 759}
 760
 761// RemoveThread permanently removes a thread (moderation action β€” admin role only).
 762func RemoveThread(cur realm, channel string, threadID uint64) {
 763	assertCallerIsAdminRole()
 764	caller := unsafe.PreviousRealm().Address()
 765	threadKey := channel + "/" + strconv.FormatUint(threadID, 10)
 766	val, exists := threads.Get(threadKey)
 767	if !exists {
 768		panic("thread not found")
 769	}
 770
 771	t := val.(*Thread)
 772	if !t.Deleted {
 773		live := getLiveThreadCount(channel)
 774		if live > 0 {
 775			threadLive.Set(channel, live-1)
 776		}
 777		removeLiveThreadID(channel, threadID)
 778		enqueueThreadTomb(channel, threadID) // B2: queue for hard-GC (skip if already tombstoned)
 779	}
 780	t.Deleted = true
 781	t.Hidden = true
 782	t.Title = "[Removed by moderation]"
 783	t.Body = ""
 784	threads.Set(threadKey, t)
 785
 786	chain.Emit("ThreadRemoved",
 787		"channel", channel,
 788		"threadId", strconv.FormatUint(threadID, 10),
 789		"admin", caller.String(),
 790	)
 791}
 792
 793// SweepTombstones hard-removes up to `limit` soft-deleted threads in a channel
 794// (and all of their remaining reply state), reclaiming AVL storage so that
 795// post+delete spam can no longer accrete permanent state (B2).
 796//
 797// Permissionless GC by design: anyone may call it. Released storage deposits go
 798// to the calling tx per the chain's deposit policy (an explicit caller bounty);
 799// on restricted-denom chains (e.g. test12/test13 `restricted_denoms=["ugnot"]`)
 800// they route to the StorageFeeCollector instead β€” so this is a state-shrink/
 801// hygiene primitive, not a user-refund path (see plan B2 / Q11).
 802//
 803// Bounded + idempotent: each soft-deleted thread holds at most
 804// MaxRepliesPerThread reply nodes, so keep `limit` small (1–5) to stay well
 805// within block gas; re-running drains the next batch and stops at 0.
 806// Returns the number of threads swept.
 807func SweepTombstones(cur realm, channel string, limit int) int {
 808	assertNotPaused()
 809	if limit <= 0 {
 810		return 0
 811	}
 812
 813	tomb := getThreadTomb(channel)
 814	n := limit
 815	if n > len(tomb) {
 816		n = len(tomb)
 817	}
 818
 819	for i := 0; i < n; i++ {
 820		threadKey := channel + "/" + strconv.FormatUint(tomb[i], 10)
 821		// Collect-then-remove: read the live reply IDs (a stored slice) and remove
 822		// each reply node β€” we never iterate the tree we mutate (AVL footgun).
 823		for _, rid := range getLiveReplyIDs(threadKey) {
 824			replies.Remove(threadKey + "/" + strconv.FormatUint(rid, 10))
 825		}
 826		replyLiveIDs.Remove(threadKey)
 827		replyLive.Remove(threadKey)
 828		replyCount.Remove(threadKey)
 829		flags.Remove(threadKey)
 830		threads.Remove(threadKey)
 831	}
 832
 833	// Drop the processed prefix; copy the tail into a fresh slice to avoid aliasing.
 834	remaining := append([]uint64{}, tomb[n:]...)
 835	if len(remaining) == 0 {
 836		threadTomb.Remove(channel)
 837	} else {
 838		threadTomb.Set(channel, remaining)
 839	}
 840
 841	if n > 0 {
 842		chain.Emit("TombstonesSwept",
 843			"channel", channel,
 844			"count", strconv.Itoa(n),
 845		)
 846	}
 847	return n
 848}
 849
 850// GetTombstoneCount returns how many soft-deleted threads in a channel are still
 851// awaiting hard-GC (read-only; lets ops/indexers decide when to call SweepTombstones).
 852func GetTombstoneCount(channel string) int {
 853	return len(getThreadTomb(channel))
 854}
 855
 856// ── Render ───────────────────────────────────────────────────
 857// CRITICAL: Output format MUST match parser.ts regex patterns.
 858
 859func Render(path string) string {
 860	if path == "" {
 861		return renderHome()
 862	}
 863	if strings.HasPrefix(path, "__acl/") {
 864		channelName := strings.TrimPrefix(path, "__acl/")
 865		return renderACL(channelName)
 866	}
 867	if strings.HasPrefix(path, "__member/") {
 868		addr := strings.TrimPrefix(path, "__member/")
 869		roles := GetMemberRoles(address(addr))
 870		if roles == "" {
 871			return "not found"
 872		}
 873		return "roles:" + roles
 874	}
 875	if strings.HasPrefix(path, "_channel/") {
 876		channelName := strings.TrimPrefix(path, "_channel/")
 877		return renderChannel(channelName)
 878	}
 879
 880	// Check for "channel/threadId" pattern, optionally with a "?page=N" suffix
 881	// for reply pagination (B1).
 882	parts := strings.SplitN(path, "/", 2)
 883	if len(parts) == 2 {
 884		idPart := parts[1]
 885		page := uint64(0)
 886		if qi := strings.IndexByte(idPart, '?'); qi >= 0 {
 887			page = parsePageQuery(idPart[qi+1:])
 888			idPart = idPart[:qi]
 889		}
 890		threadID, err := strconv.ParseUint(idPart, 10, 64)
 891		if err == nil {
 892			return renderThread(parts[0], threadID, page)
 893		}
 894	}
 895
 896	// Try as channel name directly
 897	if _, exists := channels.Get(path); exists {
 898		return renderChannel(path)
 899	}
 900
 901	return "# 404\nPage not found: " + path
 902}
 903
 904// renderHome produces the board home page.
 905// Format: parser.ts parseBoardHome() expects:
 906//   - [#name](:_channel/name) πŸ“’ (N threads)
 907func renderHome() string {
 908	var sb strings.Builder
 909	sb.WriteString("# MembaDAO Channels\n\n")
 910	sb.WriteString("Community discussion channels for the Memba ecosystem.\n\n")
 911	sb.WriteString(ufmt.Sprintf("**Owner:** %s | **Members:** %d\n\n", owner, members.Size()))
 912	sb.WriteString("## Channels\n\n")
 913
 914	for _, name := range channelOrder {
 915		val, exists := channels.Get(name)
 916		if !exists {
 917			continue
 918		}
 919		ch := val.(*Channel)
 920		if ch.Archived {
 921			continue
 922		}
 923
 924		count := getThreadCount(name)
 925		typeIcon := ""
 926		switch ch.Type {
 927		case ChannelAnnouncements:
 928			typeIcon = " πŸ“’"
 929		case ChannelReadonly:
 930			typeIcon = " πŸ”’"
 931		}
 932
 933		sb.WriteString(ufmt.Sprintf("- [#%s](:_channel/%s)%s (%d threads)\n",
 934			name, name, typeIcon, count))
 935	}
 936
 937	return sb.String()
 938}
 939
 940// renderChannel produces a channel's thread list.
 941// Format: parser.ts parseThreadList() expects:
 942//   ### [Title](:channel/id)
 943//   by g1addr... | N replies | block H
 944func renderChannel(channelName string) string {
 945	if _, exists := channels.Get(channelName); !exists {
 946		return "# 404\nChannel not found: " + channelName
 947	}
 948
 949	var sb strings.Builder
 950	sb.WriteString(ufmt.Sprintf("# #%s\n\n", channelName))
 951
 952	// Iterate the LIVE thread-ID index (bounded by getLiveThreadCount), newest
 953	// first. Never loop over the monotonic threadCount β€” a post+delete spam
 954	// loop inflates it without bound (gas DoS); deleted IDs are not in this index.
 955	ids := getLiveThreadIDs(channelName)
 956	if len(ids) == 0 {
 957		sb.WriteString("*No threads yet. Be the first to post!*\n")
 958		return sb.String()
 959	}
 960
 961	for i := len(ids) - 1; i >= 0; i-- {
 962		threadKey := channelName + "/" + strconv.FormatUint(ids[i], 10)
 963		val, exists := threads.Get(threadKey)
 964		if !exists {
 965			continue
 966		}
 967		t := val.(*Thread)
 968		if t.Hidden || t.Deleted {
 969			continue
 970		}
 971
 972		// Count live (non-deleted) replies β€” matches what renderThread shows.
 973		rCount := getLiveReplyCount(threadKey)
 974		authorStr := truncAddr(t.Author)
 975
 976		sb.WriteString(ufmt.Sprintf("### [%s](:%s/%d)\n", sanitizeForRender(t.Title), channelName, t.ID))
 977		sb.WriteString(ufmt.Sprintf("by %s | %d replies | block %d\n\n", authorStr, rCount, t.BlockH))
 978	}
 979
 980	return sb.String()
 981}
 982
 983// renderThread produces a single thread with replies.
 984// Format: parser.ts parseThreadDetail() expects:
 985//   # Title
 986//   body
 987//   ---
 988//   *Posted by g1addr at block H* *(edited at block M)*
 989//   ## Replies
 990//   **g1addr...** (block H) *(edited)*
 991//   reply body
 992//   ---
 993func renderThread(channelName string, threadID, page uint64) string {
 994	threadKey := channelName + "/" + strconv.FormatUint(threadID, 10)
 995	val, exists := threads.Get(threadKey)
 996	if !exists {
 997		return "# 404\nThread not found"
 998	}
 999
1000	t := val.(*Thread)
1001
1002	// Suppress content for hidden (flag-auto-hidden or admin-hidden) and
1003	// soft-deleted threads. renderChannel omits these from the list, but the
1004	// direct path Render("channel/id") must not leak the original title/body.
1005	if t.Hidden || t.Deleted {
1006		return "# Thread unavailable\n\n*This thread has been hidden or removed.*\n"
1007	}
1008
1009	var sb strings.Builder
1010	sb.WriteString(ufmt.Sprintf("# %s\n\n", sanitizeForRender(t.Title)))
1011	sb.WriteString(sanitizeForRender(t.Body) + "\n\n")
1012	sb.WriteString("---\n\n")
1013	sb.WriteString(ufmt.Sprintf("*Posted by %s at block %d*", string(t.Author), t.BlockH))
1014	if t.Edited {
1015		sb.WriteString(ufmt.Sprintf(" *(edited at block %d)*", t.EditedAt))
1016	}
1017	sb.WriteString("\n\n")
1018
1019	// B1: render only LIVE replies (bounded by MaxRepliesPerThread), paginated to
1020	// a fixed window so render cost is O(ReplyPageSize) regardless of total churn.
1021	// Never iterate the monotonic replyCount β€” that is the render-DoS surface.
1022	liveIDs := getLiveReplyIDs(threadKey) // ascending (oldest→newest), <= MaxRepliesPerThread
1023	total := uint64(len(liveIDs))
1024	if total > 0 {
1025		totalPages := (total + ReplyPageSize - 1) / ReplyPageSize
1026		// page 1 = oldest window … totalPages = newest. Default (0) and any
1027		// out-of-range value snap to the newest page (most-recent replies).
1028		if page == 0 || page > totalPages {
1029			page = totalPages
1030		}
1031		start := (page - 1) * ReplyPageSize
1032		end := start + ReplyPageSize
1033		if end > total {
1034			end = total
1035		}
1036
1037		sb.WriteString("## Replies\n\n")
1038		if totalPages > 1 {
1039			sb.WriteString(ufmt.Sprintf(
1040				"*Showing %d–%d of %d β€’ page %d/%d β€” older: `?page=%d`, newer: `?page=%d`*\n\n",
1041				start+1, end, total, page, totalPages, pageClamp(page-1, totalPages), pageClamp(page+1, totalPages)))
1042		}
1043
1044		for i := start; i < end; i++ {
1045			replyKey := threadKey + "/" + strconv.FormatUint(liveIDs[i], 10)
1046			rval, rexists := replies.Get(replyKey)
1047			if !rexists {
1048				continue // live index never points at a removed reply, but stay safe
1049			}
1050			r := rval.(*Reply)
1051			authorStr := truncAddr(r.Author)
1052			editStr := ""
1053			if r.Edited {
1054				editStr = " *(edited)*"
1055			}
1056			sb.WriteString(ufmt.Sprintf("**%s** (block %d)%s\n\n", authorStr, r.BlockH, editStr))
1057			sb.WriteString(sanitizeForRender(r.Body) + "\n\n")
1058			sb.WriteString("---\n\n")
1059		}
1060	}
1061
1062	return sb.String()
1063}
1064
1065// parsePageQuery extracts the page number from a "?page=N" query string (also
1066// tolerates extra &-separated params). Returns 0 (= default/newest) on absence
1067// or parse error.
1068func parsePageQuery(q string) uint64 {
1069	for _, kv := range strings.Split(q, "&") {
1070		if strings.HasPrefix(kv, "page=") {
1071			if n, err := strconv.ParseUint(strings.TrimPrefix(kv, "page="), 10, 64); err == nil {
1072				return n
1073			}
1074		}
1075	}
1076	return 0
1077}
1078
1079// pageClamp keeps a page link within [1, totalPages].
1080func pageClamp(p, totalPages uint64) uint64 {
1081	if p < 1 {
1082		return 1
1083	}
1084	if p > totalPages {
1085		return totalPages
1086	}
1087	return p
1088}
1089
1090// renderACL produces the ACL response for a channel.
1091// Format: parser.ts parseACL() expects:
1092//   read:role1,role2
1093//   write:role1,role2,role3
1094//   type:text
1095func renderACL(channelName string) string {
1096	val, exists := channels.Get(channelName)
1097	if !exists {
1098		return "not found"
1099	}
1100	ch := val.(*Channel)
1101
1102	var sb strings.Builder
1103	sb.WriteString("read:" + strings.Join(ch.ReadRoles, ",") + "\n")
1104	sb.WriteString("write:" + strings.Join(ch.WriteRoles, ",") + "\n")
1105	sb.WriteString("type:" + string(ch.Type) + "\n")
1106
1107	return sb.String()
1108}
1109
1110// ── Helpers ──────────────────────────────────────────────────
1111
1112func getChannel(name string) *Channel {
1113	val, exists := channels.Get(name)
1114	if !exists {
1115		panic("channel not found: " + name)
1116	}
1117	return val.(*Channel)
1118}
1119
1120func getThreadCount(channel string) uint64 {
1121	val, exists := threadCount.Get(channel)
1122	if !exists {
1123		return 0
1124	}
1125	return val.(uint64)
1126}
1127
1128// getLiveThreadCount returns the number of non-deleted threads in a channel.
1129// Unlike getThreadCount (monotonic ID counter), this decreases when threads
1130// are deleted, so deleted threads free up slots under MaxThreadsPerChan.
1131func getLiveThreadCount(channel string) uint64 {
1132	val, exists := threadLive.Get(channel)
1133	if !exists {
1134		return 0
1135	}
1136	return val.(uint64)
1137}
1138
1139// ── Live thread-ID index ─────────────────────────────────────
1140// renderChannel must iterate only live (non-deleted) thread IDs, never the
1141// monotonic threadCount: a member can post+delete in a loop to inflate
1142// threadCount without bound (the live cap only limits live threads), turning
1143// renderChannel into an unbounded O(threadCount) scan (gas DoS). This index
1144// stays bounded by getLiveThreadCount (<= MaxThreadsPerChan).
1145
1146func getLiveThreadIDs(channel string) []uint64 {
1147	if val, exists := threadLiveIDs.Get(channel); exists {
1148		return val.([]uint64)
1149	}
1150	return nil
1151}
1152
1153func addLiveThreadID(channel string, id uint64) {
1154	threadLiveIDs.Set(channel, append(getLiveThreadIDs(channel), id))
1155}
1156
1157func removeLiveThreadID(channel string, id uint64) {
1158	ids := getLiveThreadIDs(channel)
1159	for i, x := range ids {
1160		if x == id {
1161			threadLiveIDs.Set(channel, append(ids[:i], ids[i+1:]...))
1162			return
1163		}
1164	}
1165}
1166
1167// ── Live reply-ID index ──────────────────────────────────────
1168// Same rationale as the thread index, one level down: renderThread must iterate
1169// only live reply IDs (bounded by MaxRepliesPerThread), never the monotonic
1170// replyCount, or a member can post+delete replies in a loop to make a thread
1171// unrenderable. Keyed by "channel/threadId".
1172
1173func getLiveReplyCount(threadKey string) uint64 {
1174	if val, exists := replyLive.Get(threadKey); exists {
1175		return val.(uint64)
1176	}
1177	return 0
1178}
1179
1180func getLiveReplyIDs(threadKey string) []uint64 {
1181	if val, exists := replyLiveIDs.Get(threadKey); exists {
1182		return val.([]uint64)
1183	}
1184	return nil
1185}
1186
1187func addLiveReplyID(threadKey string, id uint64) {
1188	replyLiveIDs.Set(threadKey, append(getLiveReplyIDs(threadKey), id))
1189}
1190
1191func removeLiveReplyID(threadKey string, id uint64) {
1192	ids := getLiveReplyIDs(threadKey)
1193	for i, x := range ids {
1194		if x == id {
1195			replyLiveIDs.Set(threadKey, append(ids[:i], ids[i+1:]...))
1196			return
1197		}
1198	}
1199}
1200
1201// ── Thread tombstone queue (B2 hard-GC) ──────────────────────
1202// Soft-deleted thread IDs awaiting permanent removal by SweepTombstones. A queue
1203// (not a scan of the threads tree) keeps the sweep O(limit) β€” it never walks the
1204// unbounded set of past tombstones.
1205
1206func getThreadTomb(channel string) []uint64 {
1207	if val, exists := threadTomb.Get(channel); exists {
1208		return val.([]uint64)
1209	}
1210	return nil
1211}
1212
1213func enqueueThreadTomb(channel string, id uint64) {
1214	threadTomb.Set(channel, append(getThreadTomb(channel), id))
1215}
1216
1217func getReplyCount(threadKey string) uint64 {
1218	val, exists := replyCount.Get(threadKey)
1219	if !exists {
1220		return 0
1221	}
1222	return val.(uint64)
1223}
1224
1225func truncAddr(addr address) string {
1226	s := string(addr)
1227	if len(s) > 13 {
1228		return s[:10] + "..."
1229	}
1230	return s
1231}
1232
1233// isValidRoles validates a comma-separated role list contains only safe characters.
1234// Allowed: a-z, A-Z, 0-9, hyphen, comma. No spaces, no markdown chars, no null bytes.
1235func isValidRoles(s string) bool {
1236	if len(s) > 200 {
1237		return false
1238	}
1239	for _, c := range s {
1240		if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == ',') {
1241			return false
1242		}
1243	}
1244	return true
1245}
1246
1247// sanitizeForRender strips markdown-sensitive characters to prevent injection.
1248func sanitizeForRender(s string) string {
1249	var out strings.Builder
1250	for _, c := range s {
1251		switch c {
1252		case '[', ']', '(', ')', '#', '*', '`', '!', '<', '>', '|', '\\', '_', '~', '\n', '\r', '\t':
1253			continue
1254		default:
1255			out.WriteRune(c)
1256		}
1257	}
1258	return out.String()
1259}
1260
1261// ── ACL Enforcement ─────────────────────────────────────────
1262
1263func assertCallerIsOwner() {
1264	caller := unsafe.PreviousRealm().Address()
1265	if caller != owner {
1266		panic("unauthorized: caller " + caller.String() + " is not the owner")
1267	}
1268}
1269
1270func assertCallerIsMember(caller address) {
1271	if _, exists := members.Get(caller.String()); !exists {
1272		panic("unauthorized: caller " + caller.String() + " is not a member")
1273	}
1274}
1275
1276// assertCallerHasWriteAccess checks that the caller is a member AND has at least
1277// one role that matches the channel's WriteRoles.
1278func assertCallerHasWriteAccess(caller address, ch *Channel) {
1279	rolesStr, exists := members.Get(caller.String())
1280	if !exists {
1281		panic("unauthorized: caller " + caller.String() + " is not a member")
1282	}
1283
1284	callerRoles := strings.Split(rolesStr.(string), ",")
1285	for _, cr := range callerRoles {
1286		cr = strings.TrimSpace(cr)
1287		for _, wr := range ch.WriteRoles {
1288			if cr == wr {
1289				return // Access granted
1290			}
1291		}
1292	}
1293
1294	panic("unauthorized: caller " + caller.String() + " lacks write access to channel " + ch.Name)
1295}
1296
1297func hasRole(caller address, role string) bool {
1298	rolesStr, exists := members.Get(caller.String())
1299	if !exists {
1300		return false
1301	}
1302	callerRoles := strings.Split(rolesStr.(string), ",")
1303	for _, cr := range callerRoles {
1304		if strings.TrimSpace(cr) == role {
1305			return true
1306		}
1307	}
1308	return false
1309}
1310
1311// assertCallerIsAdminRole checks that the caller is a member with the "admin" role.
1312// Used for moderation actions (RemoveThread, UnhideThread) so any admin can moderate,
1313// not just the single owner.
1314func assertCallerIsAdminRole() {
1315	caller := unsafe.PreviousRealm().Address()
1316	if !hasRole(caller, "admin") {
1317		panic("unauthorized: caller " + caller.String() + " does not have admin role")
1318	}
1319}
1320
1321// isValidChannelName validates that a channel name contains only
1322// lowercase alphanumeric characters and hyphens, and does not start with underscore.
1323func isValidChannelName(name string) bool {
1324	if len(name) == 0 || len(name) > 50 {
1325		return false
1326	}
1327	if name[0] == '_' {
1328		return false // Prevent collision with __acl/, __member/ paths
1329	}
1330	for _, c := range name {
1331		if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
1332			return false
1333		}
1334	}
1335	return true
1336}