Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

memba_feedback_v2.gno

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