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