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}