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}