Search Apps Documentation Source Content File Folder Download Copy Actions Download

memba_feedback_v2.gno

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