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_dao_channels_v2.gno

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