Search Apps Documentation Source Content File Folder Download Copy Actions Download

memba_feed_v1.gno

31.16 Kb · 996 lines
  1package memba_feed_v1
  2
  3// memba_feed_v1 — Global social feed realm for Memba (W7.2 P0).
  4//
  5// OPEN-WRITE: any wallet may post. This inverts every assumption of the
  6// DAO-scoped memba_dao_channels_v2 (members-only, 20 channels / 500 threads),
  7// so the realm is NEW — what is ported VERBATIM from channels_v2 is its
  8// hardening discipline:
  9//
 10//   B1 render-DoS bounds — reads NEVER iterate the monotonic nextPostID.
 11//     Live (visible) posts are tracked in dedicated AVL indexes and every
 12//     read path is paginated to a fixed window. channels_v2 used []uint64
 13//     slices (safe under its 500-per-channel cap); an open feed is unbounded,
 14//     so the live indexes here are composite-key AVL trees instead —
 15//     O(log n) insert/remove, O(page) scan, no single node that grows forever.
 16//   B2 state-shrink — author deletes soft-delete + enqueue a tombstone;
 17//     SweepTombstones hard-removes nodes (bounded, permissionless GC).
 18//   B3 pause policy — every user-facing write is blocked while paused;
 19//     owner/moderation stays operational (no funds in this realm).
 20//   Flag pipeline — one flag per address per post, threshold auto-hide.
 21//     Open write invites brigading, so the threshold is higher than
 22//     channels_v2's 3 and flagging is further gated by account age and a
 23//     per-day flag budget.
 24//
 25// Anti-spam (open write has no membership gate to lean on):
 26//   - per-address block cooldown between posts, stricter for accounts first
 27//     seen fewer than YoungAccountBlocks ago;
 28//   - body length cap; media CID count cap (CIDs stored on-chain only —
 29//     pinning/serving is the backend's PinMedia pipeline, P2).
 30//
 31// Reads: exported *JSON funcs via RPC vm/qeval — cursor-paginated, O(limit)
 32// at any depth (the app reads from the indexer; these are the fallback).
 33// Render() is the human gnoweb view: bounded pages with a hard depth cap
 34// (MaxRenderPage) — deep history belongs to the cursor API, not qrender.
 35//
 36// Moderation P0: realm owner only (ModRemovePost / UnhidePost / BanAuthor is
 37// W8.2's moderation board via a daokit moderator role — p/samcrew/modboard).
 38// Every moderation action emits for public audit.
 39//
 40// This realm holds NO funds: no banker, no OriginSend, no fee lane. A future
 41// tipping lane is a separate SAFETY_GATED flag + fee-spine lane "feed".
 42
 43import (
 44	"strconv"
 45	"strings"
 46
 47	"gno.land/p/nt/avl/v0"
 48	"gno.land/p/nt/ufmt/v0"
 49
 50	"chain"
 51	"chain/runtime"
 52	"chain/runtime/unsafe"
 53)
 54
 55// ── Constants ────────────────────────────────────────────────
 56
 57const (
 58	MaxBodyLen   = 1000 // post body (roadmap 4.1)
 59	MaxMediaCIDs = 4    // stored per post; pin/serve pipeline is P2
 60	MaxCIDLen    = 128  // defensive cap on a single CID string
 61
 62	FeedPageSize  = 20  // posts per rendered page / default JSON window
 63	MaxPageLimit  = 100 // hard cap on any JSON read window (DoS guard)
 64	MaxRenderPage = 50  // deepest gnoweb page; beyond → use the cursor API
 65
 66	// B1 reply bound (mirrors channels_v2's MaxRepliesPerThread). Caps the
 67	// LIVE reply set per post so (a) the byParent index under any one parent is
 68	// bounded, and (b) SweepTombstones can range-delete a swept parent's reply
 69	// links in bounded work. Deleting a reply frees a slot.
 70	MaxRepliesPerPost = 500
 71
 72	// Flag pipeline. channels_v2 auto-hides at 3 member flags; the feed is
 73	// open-write, so the threshold starts higher and flagging is rate-limited.
 74	// These are damping knobs, not a brigade-proof gate: 5 aged sybils can
 75	// still hide a post (the age gate is one post + a one-time wait, farmable
 76	// in parallel). They raise the cost and slow coordination; genuine brigade
 77	// resistance is the W8.2 moderation board (reversible mod actions + audit).
 78	// All are governance-tunable post-deploy via a future realm version; they
 79	// are constants here so the adversarial tests pin their behavior.
 80	FlagThreshold        = 5     // unique flags before auto-hide
 81	MinAccountAgeForFlag = 1200  // blocks since first post (~1-2h)
 82	FlagsPerDayBudget    = 10    // per address per BlocksPerDay window
 83	BlocksPerDay         = 17280 // ~5s blocks; approximation is fine
 84
 85	// Posting cooldowns (blocks). Young accounts (first seen < YoungAccountBlocks
 86	// ago) wait longer between posts — cheap sybil throttle.
 87	MinPostIntervalBlocks      = 2
 88	YoungMinPostIntervalBlocks = 12
 89	YoungAccountBlocks         = 17280 // first ~day
 90)
 91
 92// ── Types ────────────────────────────────────────────────────
 93
 94type Post struct {
 95	ID        uint64
 96	Author    address
 97	Body      string
 98	MediaCIDs []string // IPFS CIDs only; serving goes through the backend proxy
 99	ReplyTo   uint64   // 0 = top-level
100	RepostOf  uint64   // 0 = original (entrypoint lands in P1; field is schema-stable)
101	BlockH    int64
102	EditedAt  int64 // block height of last edit (0 = never)
103	FlagCount int
104	Hidden    bool // flag auto-hide or moderation hide
105	Deleted   bool // author tombstone (awaiting SweepTombstones hard-GC)
106}
107
108type flagBudget struct {
109	DayStartH int64
110	Used      int
111}
112
113// ── State ────────────────────────────────────────────────────
114
115var (
116	posts *avl.Tree // padID(id) -> *Post (live + hidden + not-yet-swept tombstones)
117
118	// Live indexes (B1): ONLY visible (not hidden, not deleted) posts.
119	// Every read path iterates one of these — never `posts`, never nextPostID.
120	liveFeed   *avl.Tree // padID(id) -> true
121	byAuthor   *avl.Tree // author + ":" + padID(id) -> true
122	byParent   *avl.Tree // padID(parent) + ":" + padID(child) -> true
123	liveCount  uint64    // number of keys in liveFeed
124	replyCount *avl.Tree // padID(parent) -> uint64 (live replies)
125
126	nextPostID uint64 // monotonic; NEVER iterated by reads
127
128	// Anti-spam / flag state.
129	lastPostH  *avl.Tree // addr -> int64 (last CreatePost height)
130	firstSeenH *avl.Tree // addr -> int64 (first write interaction height)
131	flags      *avl.Tree // padID(id) -> *avl.Tree (flagger addr -> true)
132	flagSpend  *avl.Tree // addr -> *flagBudget
133
134	// Soft-deleted post IDs awaiting hard-GC (B2). An AVL tree (not a slice):
135	// a slice re-serializes wholesale on every enqueue, so post+delete spam
136	// would inflate the gas of every write touching it (a slow write-path DoS).
137	// The tree gives O(log n) enqueue and lets the sweep drain a bounded
138	// ascending window without rewriting the backlog.
139	tombstones *avl.Tree // padID(id) -> true
140
141	paused bool
142	// Deployer address (samcrew-core-test1 multisig on testnet), captured at
143	// package load — same pattern as channels_v2.
144	owner = unsafe.OriginCaller()
145)
146
147func init() {
148	posts = avl.NewTree()
149	liveFeed = avl.NewTree()
150	byAuthor = avl.NewTree()
151	byParent = avl.NewTree()
152	replyCount = avl.NewTree()
153	lastPostH = avl.NewTree()
154	firstSeenH = avl.NewTree()
155	flags = avl.NewTree()
156	flagSpend = avl.NewTree()
157	tombstones = avl.NewTree()
158	nextPostID = 1
159}
160
161// ── Helpers ──────────────────────────────────────────────────
162
163// padID zero-pads to 12 digits so AVL string order == numeric order.
164// 12 digits ≥ 31,000 years of one post per block — effectively unbounded.
165func padID(id uint64) string {
166	s := strconv.FormatUint(id, 10)
167	for len(s) < 12 {
168		s = "0" + s
169	}
170	return s
171}
172
173func getPost(id uint64) (*Post, bool) {
174	v, ok := posts.Get(padID(id))
175	if !ok {
176		return nil, false
177	}
178	return v.(*Post), true
179}
180
181func mustGetPost(id uint64) *Post {
182	p, ok := getPost(id)
183	if !ok {
184		panic("post not found: " + strconv.FormatUint(id, 10))
185	}
186	return p
187}
188
189func authorKey(addr address, id uint64) string { return addr.String() + ":" + padID(id) }
190func parentKey(parent, child uint64) string    { return padID(parent) + ":" + padID(child) }
191
192func getReplyCount(parent uint64) uint64 {
193	if v, ok := replyCount.Get(padID(parent)); ok {
194		return v.(uint64)
195	}
196	return 0
197}
198
199// addToLiveIndexes registers a visible post in every live index (B1).
200//
201// A reply is only linked into its parent's byParent/replyCount indexes when the
202// parent is still LIVE. On CreatePost the parent was just verified live, so this
203// is always true there. The guard matters on the UnhidePost re-add path: if the
204// parent was deleted+swept while the reply sat hidden, re-linking would recreate
205// an orphan byParent/replyCount entry under a parent id no longer in `posts`
206// (never sweepable again) — the exact leak SweepTombstones' byParent-prefix
207// cleanup was added to prevent. An unhidden reply whose parent is gone simply
208// becomes standalone live content, which is how a reply to a removed parent is
209// already treated everywhere else.
210func addToLiveIndexes(p *Post) {
211	liveFeed.Set(padID(p.ID), true)
212	byAuthor.Set(authorKey(p.Author, p.ID), true)
213	liveCount++
214	if p.ReplyTo != 0 && liveFeed.Has(padID(p.ReplyTo)) {
215		byParent.Set(parentKey(p.ReplyTo, p.ID), true)
216		replyCount.Set(padID(p.ReplyTo), getReplyCount(p.ReplyTo)+1)
217	}
218}
219
220// removeFromLiveIndexes drops a post from every live index (hide/delete).
221func removeFromLiveIndexes(p *Post) {
222	if _, ok := liveFeed.Get(padID(p.ID)); !ok {
223		return // already invisible (e.g. delete after auto-hide)
224	}
225	liveFeed.Remove(padID(p.ID))
226	byAuthor.Remove(authorKey(p.Author, p.ID))
227	if liveCount > 0 {
228		liveCount--
229	}
230	if p.ReplyTo != 0 {
231		byParent.Remove(parentKey(p.ReplyTo, p.ID))
232		if rc := getReplyCount(p.ReplyTo); rc > 0 {
233			replyCount.Set(padID(p.ReplyTo), rc-1)
234		}
235	}
236}
237
238func assertNotPaused() {
239	if paused {
240		panic("realm is paused — emergency maintenance")
241	}
242}
243
244func assertCallerIsOwner() {
245	caller := unsafe.PreviousRealm().Address()
246	if caller != owner {
247		panic("unauthorized: caller " + caller.String() + " is not the owner")
248	}
249}
250
251// touchFirstSeen records the first write interaction height for an address.
252func touchFirstSeen(addr address, h int64) {
253	if _, ok := firstSeenH.Get(addr.String()); !ok {
254		firstSeenH.Set(addr.String(), h)
255	}
256}
257
258func accountAge(addr address, now int64) int64 {
259	if v, ok := firstSeenH.Get(addr.String()); ok {
260		return now - v.(int64)
261	}
262	return 0
263}
264
265// ── Emergency pause (B3 — one policy, enforced everywhere) ───
266// While paused every user-facing content write is blocked. Owner moderation
267// and pause management stay live: pause halts user activity during an
268// incident, but the owner must stay able to act. No funds here, so there is
269// no value-exit exemption to carve out.
270
271func PauseRealm(cur realm) {
272	assertCallerIsOwner()
273	paused = true
274	chain.Emit("RealmPaused", "by", owner.String())
275}
276
277func UnpauseRealm(cur realm) {
278	assertCallerIsOwner()
279	paused = false
280	chain.Emit("RealmUnpaused", "by", owner.String())
281}
282
283func IsPaused() bool { return paused }
284
285// TransferOwnership moves realm ownership (deployer multisig rotation).
286func TransferOwnership(cur realm, newOwner address) {
287	assertCallerIsOwner()
288	if newOwner == "" {
289		panic("address cannot be empty")
290	}
291	if newOwner == owner {
292		panic("new owner is the same as current owner")
293	}
294	prev := owner
295	owner = newOwner
296	chain.Emit("OwnershipTransferred",
297		"previousOwner", prev.String(),
298		"newOwner", newOwner.String(),
299	)
300}
301
302// GetOwner returns the current realm owner address.
303func GetOwner() address { return owner }
304
305// ── Writes ───────────────────────────────────────────────────
306
307// CreatePost publishes a post (replyTo == 0) or a reply (replyTo == parent id).
308// Open write: any wallet, subject to the block cooldown and body caps.
309// Returns the new post id.
310func CreatePost(cur realm, body string, replyTo uint64) uint64 {
311	assertNotPaused()
312	caller := unsafe.PreviousRealm().Address()
313	now := runtime.ChainHeight()
314
315	// Cooldown BEFORE touching state: young accounts wait longer.
316	touchFirstSeen(caller, now)
317	interval := int64(MinPostIntervalBlocks)
318	if accountAge(caller, now) < YoungAccountBlocks {
319		interval = YoungMinPostIntervalBlocks
320	}
321	if v, ok := lastPostH.Get(caller.String()); ok {
322		if now-v.(int64) < interval {
323			panic(ufmt.Sprintf("posting too fast: wait %d blocks between posts", interval))
324		}
325	}
326
327	if len(body) == 0 || len(body) > MaxBodyLen {
328		panic(ufmt.Sprintf("body must be 1-%d characters", MaxBodyLen))
329	}
330
331	var parent *Post
332	if replyTo != 0 {
333		parent = mustGetPost(replyTo)
334		if parent.Deleted {
335			panic("cannot reply to a deleted post")
336		}
337		if parent.Hidden {
338			panic("cannot reply to a hidden post")
339		}
340		// B1: bound the LIVE reply set so the byParent index under one parent
341		// stays bounded (and a swept parent's link cleanup stays bounded).
342		if getReplyCount(replyTo) >= MaxRepliesPerPost {
343			panic(ufmt.Sprintf("reply limit reached: %d per post", MaxRepliesPerPost))
344		}
345	}
346
347	id := nextPostID
348	nextPostID++
349
350	p := &Post{
351		ID:      id,
352		Author:  caller,
353		Body:    body,
354		ReplyTo: replyTo,
355		BlockH:  now,
356	}
357	posts.Set(padID(id), p)
358	addToLiveIndexes(p)
359	lastPostH.Set(caller.String(), now)
360
361	chain.Emit("PostCreated",
362		"postId", strconv.FormatUint(id, 10),
363		"author", caller.String(),
364		"replyTo", strconv.FormatUint(replyTo, 10),
365		"body", body,
366	)
367	return id
368}
369
370// EditPost lets the author replace the body of a visible post.
371func EditPost(cur realm, id uint64, newBody string) {
372	assertNotPaused()
373	caller := unsafe.PreviousRealm().Address()
374
375	p := mustGetPost(id)
376	if p.Author != caller {
377		panic("only the author can edit")
378	}
379	if p.Deleted {
380		panic("cannot edit a deleted post")
381	}
382	if p.Hidden {
383		panic("cannot edit a hidden post")
384	}
385	if len(newBody) == 0 || len(newBody) > MaxBodyLen {
386		panic(ufmt.Sprintf("body must be 1-%d characters", MaxBodyLen))
387	}
388
389	p.Body = newBody
390	p.EditedAt = runtime.ChainHeight()
391	posts.Set(padID(id), p)
392
393	chain.Emit("PostEdited",
394		"postId", strconv.FormatUint(id, 10),
395		"author", caller.String(),
396		"body", newBody,
397	)
398}
399
400// DeletePost soft-deletes the author's own post: content cleared, dropped from
401// every live index (frees render slots immediately — B1), queued for hard-GC
402// (B2). Replies stay: they render as replies to an unavailable post.
403func DeletePost(cur realm, id uint64) {
404	assertNotPaused()
405	caller := unsafe.PreviousRealm().Address()
406
407	p := mustGetPost(id)
408	if p.Author != caller {
409		panic("only the author can delete")
410	}
411	if p.Deleted {
412		panic("post already deleted")
413	}
414
415	removeFromLiveIndexes(p)
416	p.Deleted = true
417	p.Body = ""
418	p.MediaCIDs = nil
419	posts.Set(padID(id), p)
420	tombstones.Set(padID(id), true)
421
422	chain.Emit("PostDeleted",
423		"postId", strconv.FormatUint(id, 10),
424		"author", caller.String(),
425	)
426}
427
428// ── Flag pipeline ────────────────────────────────────────────
429
430// FlagPost files a community flag. Guards, in order:
431//   - flag rights are EARNED BY PARTICIPATION: the caller must have a
432//     firstSeen record (anchored by their first successful CreatePost) at
433//     least MinAccountAgeForFlag blocks old. A failed flag cannot anchor age
434//     itself — an on-chain abort reverts every write in the tx, so recording
435//     firstSeen here and then panicking would revert the record and deadlock
436//     pure flaggers; requiring a prior post is the honest, implementable
437//     account-age gate (realms cannot query global account age);
438//   - one flag per address per post;
439//   - per-day flag budget per address (blunts coordinated flag-brigades).
440//
441// At FlagThreshold unique flags the post auto-hides (reversible via UnhidePost).
442func FlagPost(cur realm, id uint64) {
443	assertNotPaused()
444	caller := unsafe.PreviousRealm().Address()
445	now := runtime.ChainHeight()
446
447	if accountAge(caller, now) < MinAccountAgeForFlag {
448		panic(ufmt.Sprintf("flagging requires having posted at least %d blocks ago", MinAccountAgeForFlag))
449	}
450
451	p := mustGetPost(id)
452	if p.Deleted {
453		panic("cannot flag a deleted post")
454	}
455	if p.Hidden {
456		panic("post is already hidden")
457	}
458
459	// Per-day budget window.
460	var fb *flagBudget
461	if v, ok := flagSpend.Get(caller.String()); ok {
462		fb = v.(*flagBudget)
463	} else {
464		fb = &flagBudget{DayStartH: now}
465	}
466	if now-fb.DayStartH >= BlocksPerDay {
467		fb.DayStartH = now
468		fb.Used = 0
469	}
470	if fb.Used >= FlagsPerDayBudget {
471		panic(ufmt.Sprintf("daily flag budget reached: %d per %d blocks", FlagsPerDayBudget, BlocksPerDay))
472	}
473
474	// Unique flaggers per post.
475	var flagTree *avl.Tree
476	if v, ok := flags.Get(padID(id)); ok {
477		flagTree = v.(*avl.Tree)
478	} else {
479		flagTree = avl.NewTree()
480	}
481	if _, already := flagTree.Get(caller.String()); already {
482		panic("already flagged")
483	}
484	flagTree.Set(caller.String(), true)
485	flags.Set(padID(id), flagTree)
486
487	fb.Used++
488	flagSpend.Set(caller.String(), fb)
489
490	p.FlagCount = flagTree.Size()
491	wasHidden := p.Hidden
492	if p.FlagCount >= FlagThreshold {
493		p.Hidden = true
494		removeFromLiveIndexes(p)
495		// A flag-hidden post is deliberately NOT tombstoned: its node and its
496		// `flags` subtree (the flagger set) are retained on purpose as the audit
497		// trail the W8.2 moderation board reviews (and UnhidePost needs, to
498		// reverse a brigade). It leaves the live indexes (B1 render bounds still
499		// hold), so this is bounded, intentional retention — not a leak. Author
500		// delete / mod-remove are the paths that tombstone for GC.
501	}
502	posts.Set(padID(id), p)
503
504	chain.Emit("PostFlagged",
505		"postId", strconv.FormatUint(id, 10),
506		"flagger", caller.String(),
507		"flagCount", strconv.Itoa(p.FlagCount),
508	)
509	if !wasHidden && p.Hidden {
510		chain.Emit("PostAutoHidden", "postId", strconv.FormatUint(id, 10))
511	}
512}
513
514// ── Moderation (P0: owner only; W8.2 moves this behind a daokit
515//    moderator role via p/samcrew/modboard) ──────────────────
516
517// ModRemovePost permanently hides a post by moderation. Emits an audited
518// ModAction. The node is tombstoned for hard-GC like an author delete.
519func ModRemovePost(cur realm, id uint64) {
520	assertCallerIsOwner()
521
522	p := mustGetPost(id)
523	if !p.Deleted {
524		removeFromLiveIndexes(p)
525		tombstones.Set(padID(id), true)
526	}
527	p.Deleted = true
528	p.Hidden = true
529	p.Body = ""
530	p.MediaCIDs = nil
531	posts.Set(padID(id), p)
532
533	chain.Emit("ModAction",
534		"action", "remove",
535		"postId", strconv.FormatUint(id, 10),
536		"moderator", unsafe.PreviousRealm().Address().String(),
537	)
538}
539
540// UnhidePost clears flags and restores a flag-hidden post to the live set.
541func UnhidePost(cur realm, id uint64) {
542	assertCallerIsOwner()
543
544	p := mustGetPost(id)
545	if p.Deleted {
546		panic("cannot unhide a deleted post")
547	}
548	if !p.Hidden {
549		panic("post is not hidden")
550	}
551	p.Hidden = false
552	p.FlagCount = 0
553	posts.Set(padID(id), p)
554	flags.Remove(padID(id))
555	addToLiveIndexes(p)
556
557	chain.Emit("ModAction",
558		"action", "unhide",
559		"postId", strconv.FormatUint(id, 10),
560		"moderator", unsafe.PreviousRealm().Address().String(),
561	)
562}
563
564// ── Tombstone sweep (B2 hard-GC, ported from channels_v2) ────
565
566// SweepTombstones hard-removes up to `limit` soft-deleted posts, reclaiming
567// AVL storage so post+delete spam cannot accrete permanent state.
568// Permissionless by design (state-shrink hygiene primitive, not a refund
569// path). Bounded + idempotent: keep `limit` small (1-10); re-running drains
570// the next batch and stops at 0. Returns the number of posts swept.
571//
572// For a swept post that was a PARENT, its surviving replies' byParent links
573// (`padID(parent):padID(child)`) would otherwise be orphaned under an id no
574// longer in `posts` — a permanent leak plus a read inconsistency (replies
575// still enumerable, getReplyCount() disagreeing). So the sweep range-deletes
576// that byParent prefix; the reply posts themselves stay as standalone live
577// content (their parent was removed by its own author). Bounded because the
578// live reply set per post is capped at MaxRepliesPerPost — but a reply-heavy
579// parent makes one sweep step do up to that many removes, so keep `limit`
580// small (1) when draining known reply-heavy tombstones.
581func SweepTombstones(cur realm, limit int) int {
582	assertNotPaused()
583	if limit <= 0 {
584		return 0
585	}
586
587	// Collect the oldest `limit` tombstoned ids (ascending), then mutate —
588	// never remove from the tree we are iterating (AVL footgun).
589	ids := []uint64{}
590	tombstones.Iterate("", "", func(key string, _ interface{}) bool {
591		ids = append(ids, idFromPadded(key))
592		return len(ids) >= limit
593	})
594
595	for _, id := range ids {
596		// Drop this parent's remaining child links (if any) so no byParent
597		// entry outlives its parent. Collect-then-remove over the prefix.
598		prefix := padID(id) + ":"
599		childKeys := []string{}
600		byParent.Iterate(prefix, prefix+"\xff", func(k string, _ interface{}) bool {
601			childKeys = append(childKeys, k)
602			return false
603		})
604		for _, k := range childKeys {
605			byParent.Remove(k)
606		}
607
608		posts.Remove(padID(id))
609		flags.Remove(padID(id))
610		replyCount.Remove(padID(id))
611		tombstones.Remove(padID(id))
612	}
613
614	if len(ids) > 0 {
615		chain.Emit("TombstonesSwept", "count", strconv.Itoa(len(ids)))
616	}
617	return len(ids)
618}
619
620// GetTombstoneCount returns how many soft-deleted posts await hard-GC.
621func GetTombstoneCount() int { return tombstones.Size() }
622
623// ── JSON reads (vm/qeval; cursor-paginated, O(limit) at any depth) ──
624
625// jsonEscape escapes a string for embedding in a JSON string literal.
626func jsonEscape(s string) string {
627	var sb strings.Builder
628	for _, c := range s {
629		switch c {
630		case '"':
631			sb.WriteString("\\\"")
632		case '\\':
633			sb.WriteString("\\\\")
634		case '\n':
635			sb.WriteString("\\n")
636		case '\r':
637			sb.WriteString("\\r")
638		case '\t':
639			sb.WriteString("\\t")
640		default:
641			if c < 0x20 {
642				continue // drop raw control chars (ufmt has no \uXXXX support)
643			}
644			sb.WriteRune(c)
645		}
646	}
647	return sb.String()
648}
649
650// postJSON is the qeval read fallback. Note it exposes `mediaCids` (P2) and
651// `repostOf` (P1) which the CANONICAL indexed path (chain.Emit → backend →
652// feed_rpc → UI) does NOT carry yet — PostCreated emits neither, the proto
653// reserves repost_of, and there is no media column. A future dev wiring the
654// qeval fallback must not assume those two are plumbed end-to-end.
655func postJSON(p *Post) string {
656	var media strings.Builder
657	media.WriteString("[")
658	for i, cid := range p.MediaCIDs {
659		if i > 0 {
660			media.WriteString(",")
661		}
662		media.WriteString("\"" + jsonEscape(cid) + "\"")
663	}
664	media.WriteString("]")
665
666	return ufmt.Sprintf(
667		`{"id":%d,"author":"%s","body":"%s","mediaCids":%s,"replyTo":%d,"repostOf":%d,"blockH":%d,"editedAt":%d,"flagCount":%d,"hidden":%s,"deleted":%s,"replies":%d}`,
668		p.ID, p.Author.String(), jsonEscape(p.Body), media.String(),
669		p.ReplyTo, p.RepostOf, p.BlockH, p.EditedAt, p.FlagCount,
670		boolStr(p.Hidden), boolStr(p.Deleted), getReplyCount(p.ID),
671	)
672}
673
674func boolStr(b bool) string {
675	if b {
676		return "true"
677	}
678	return "false"
679}
680
681func clampLimit(limit int) int {
682	if limit <= 0 {
683		return FeedPageSize
684	}
685	if limit > MaxPageLimit {
686		return MaxPageLimit
687	}
688	return limit
689}
690
691// GetPostJSON returns one post (any state — the indexer needs tombstones too).
692func GetPostJSON(id uint64) string {
693	p, ok := getPost(id)
694	if !ok {
695		return "null"
696	}
697	return postJSON(p)
698}
699
700// listWindow walks `tree` REVERSE (newest first) starting below cursorKey
701// ("" = from the newest), collecting up to limit ids via extract.
702func listWindow(tree *avl.Tree, cursorKey string, limit int, extract func(key string) uint64) []uint64 {
703	ids := []uint64{}
704	count := 0
705	tree.ReverseIterate("", cursorKey, func(key string, _ interface{}) bool {
706		id := extract(key)
707		if id != 0 {
708			ids = append(ids, id)
709			count++
710		}
711		return count >= limit
712	})
713	return ids
714}
715
716func idFromPadded(key string) uint64 {
717	n, err := strconv.ParseUint(key, 10, 64)
718	if err != nil {
719		return 0
720	}
721	return n
722}
723
724func idFromComposite(key string) uint64 {
725	i := strings.LastIndexByte(key, ':')
726	if i < 0 {
727		return 0
728	}
729	return idFromPadded(key[i+1:])
730}
731
732func idsToJSON(ids []uint64) string {
733	var sb strings.Builder
734	sb.WriteString("[")
735	for i, id := range ids {
736		if i > 0 {
737			sb.WriteString(",")
738		}
739		if p, ok := getPost(id); ok {
740			sb.WriteString(postJSON(p))
741		}
742	}
743	sb.WriteString("]")
744	return sb.String()
745}
746
747// ListFeedJSON returns the newest live posts strictly older than cursor
748// (cursor == 0 → from the top). Pass the last id of the previous window as
749// the next cursor.
750//
751// ReverseIterate treats BOTH bounds as inclusive (avl/v0 TraverseInRange), so
752// "strictly older than cursor" = end bound padID(cursor-1): ids are integers,
753// keys are fixed-width, hence key < padID(cursor) ⟺ key ≤ padID(cursor-1).
754func ListFeedJSON(cursor uint64, limit int) string {
755	limit = clampLimit(limit)
756	cursorKey := ""
757	if cursor != 0 {
758		cursorKey = padID(cursor - 1)
759	}
760	return idsToJSON(listWindow(liveFeed, cursorKey, limit, idFromPadded))
761}
762
763// ListUserJSON returns a user's newest live posts strictly older than cursor
764// (same inclusive-bound rule as ListFeedJSON).
765func ListUserJSON(addr string, cursor uint64, limit int) string {
766	limit = clampLimit(limit)
767	cursorKey := ""
768	if cursor != 0 {
769		cursorKey = addr + ":" + padID(cursor-1)
770	} else {
771		// End bound just past every "addr:XXXXXXXXXXXX" key: ':' + 0xff.
772		cursorKey = addr + ":\xff"
773	}
774	ids := []uint64{}
775	count := 0
776	byAuthor.ReverseIterate(addr+":", cursorKey, func(key string, _ interface{}) bool {
777		if id := idFromComposite(key); id != 0 {
778			ids = append(ids, id)
779			count++
780		}
781		return count >= limit
782	})
783	return idsToJSON(ids)
784}
785
786// ListRepliesJSON returns a post's live replies, OLDEST first (conversation
787// order), starting strictly after cursor (0 → from the beginning).
788func ListRepliesJSON(parent uint64, cursor uint64, limit int) string {
789	limit = clampLimit(limit)
790	start := padID(parent) + ":"
791	if cursor != 0 {
792		start = parentKey(parent, cursor) + "\x00" // strictly after the cursor child
793	}
794	ids := []uint64{}
795	count := 0
796	byParent.Iterate(start, padID(parent)+":\xff", func(key string, _ interface{}) bool {
797		if id := idFromComposite(key); id != 0 {
798			ids = append(ids, id)
799			count++
800		}
801		return count >= limit
802	})
803	return idsToJSON(ids)
804}
805
806// GetStatsJSON returns realm-level counters (monotonic id is informational —
807// no read iterates it).
808func GetStatsJSON() string {
809	return ufmt.Sprintf(`{"livePosts":%d,"nextPostId":%d,"tombstones":%d,"paused":%s}`,
810		liveCount, nextPostID, tombstones.Size(), boolStr(paused))
811}
812
813// ── Render (gnoweb human view; bounded — B1) ─────────────────
814//
815// Paths:
816//   ""            → newest window (page 1)
817//   page/K        → K-th newest window, K ≤ MaxRenderPage
818//   user/ADDR     → ADDR's newest window
819//   user/ADDR/K   → K-th window of ADDR's posts
820//   post/ID       → one post + its newest reply window
821//
822// Every page scans at most MaxRenderPage*FeedPageSize live-index keys and
823// fetches at most FeedPageSize posts — bounded regardless of feed size or
824// churn (deleted/hidden posts are not in the live indexes at all).
825
826func Render(path string) string {
827	if path == "" {
828		return renderFeedPage(1)
829	}
830	if strings.HasPrefix(path, "page/") {
831		k, err := strconv.ParseUint(strings.TrimPrefix(path, "page/"), 10, 64)
832		if err != nil || k == 0 {
833			return "# 404\nBad page"
834		}
835		return renderFeedPage(k)
836	}
837	if strings.HasPrefix(path, "user/") {
838		rest := strings.TrimPrefix(path, "user/")
839		addr := rest
840		k := uint64(1)
841		if i := strings.IndexByte(rest, '/'); i >= 0 {
842			addr = rest[:i]
843			n, err := strconv.ParseUint(rest[i+1:], 10, 64)
844			if err != nil || n == 0 {
845				return "# 404\nBad page"
846			}
847			k = n
848		}
849		return renderUserPage(addr, k)
850	}
851	if strings.HasPrefix(path, "post/") {
852		id, err := strconv.ParseUint(strings.TrimPrefix(path, "post/"), 10, 64)
853		if err != nil {
854			return "# 404\nBad post id"
855		}
856		return renderPost(id)
857	}
858	return "# 404\nPage not found: " + path
859}
860
861// collectPage walks a live index reverse and returns the ids for window k
862// (1-based). Scan is bounded: k is capped at MaxRenderPage by callers.
863func collectPage(tree *avl.Tree, prefix, endKey string, k uint64) []uint64 {
864	skip := (k - 1) * FeedPageSize
865	ids := []uint64{}
866	seen := uint64(0)
867	tree.ReverseIterate(prefix, endKey, func(key string, _ interface{}) bool {
868		if seen >= skip {
869			var id uint64
870			if prefix == "" {
871				id = idFromPadded(key)
872			} else {
873				id = idFromComposite(key)
874			}
875			if id != 0 {
876				ids = append(ids, id)
877			}
878		}
879		seen++
880		return len(ids) >= FeedPageSize
881	})
882	return ids
883}
884
885func renderPostLine(sb *strings.Builder, p *Post) {
886	sb.WriteString(ufmt.Sprintf("**%s** (block %d)", truncAddr(p.Author), p.BlockH))
887	if p.EditedAt != 0 {
888		sb.WriteString(" *(edited)*")
889	}
890	if p.ReplyTo != 0 {
891		sb.WriteString(ufmt.Sprintf(" — reply to [post %d](:post/%d)", p.ReplyTo, p.ReplyTo))
892	}
893	sb.WriteString("\n\n")
894	sb.WriteString(sanitizeForRender(p.Body) + "\n\n")
895	rc := getReplyCount(p.ID)
896	sb.WriteString(ufmt.Sprintf("[permalink](:post/%d) | %d replies\n\n---\n\n", p.ID, rc))
897}
898
899func renderFeedPage(k uint64) string {
900	if k > MaxRenderPage {
901		return ufmt.Sprintf("# Memba Feed\n\n*Pages beyond %d are not rendered — use the JSON cursor API (ListFeedJSON) or the Memba app.*\n", MaxRenderPage)
902	}
903	var sb strings.Builder
904	sb.WriteString("# Memba Feed\n\n")
905	sb.WriteString(ufmt.Sprintf("**Live posts:** %d\n\n", liveCount))
906
907	ids := collectPage(liveFeed, "", "", k)
908	if len(ids) == 0 {
909		sb.WriteString("*No posts on this page.*\n")
910		return sb.String()
911	}
912	for _, id := range ids {
913		if p, ok := getPost(id); ok {
914			renderPostLine(&sb, p)
915		}
916	}
917	sb.WriteString(ufmt.Sprintf("*page %d — older: `:page/%d`*\n", k, k+1))
918	return sb.String()
919}
920
921func renderUserPage(addr string, k uint64) string {
922	if k > MaxRenderPage {
923		return ufmt.Sprintf("# Feed — %s\n\n*Pages beyond %d are not rendered — use ListUserJSON or the Memba app.*\n", addr, MaxRenderPage)
924	}
925	var sb strings.Builder
926	sb.WriteString(ufmt.Sprintf("# Feed — %s\n\n", addr))
927	ids := collectPage(byAuthor, addr+":", addr+":\xff", k)
928	if len(ids) == 0 {
929		sb.WriteString("*No posts on this page.*\n")
930		return sb.String()
931	}
932	for _, id := range ids {
933		if p, ok := getPost(id); ok {
934			renderPostLine(&sb, p)
935		}
936	}
937	sb.WriteString(ufmt.Sprintf("*page %d — older: `:user/%s/%d`*\n", k, addr, k+1))
938	return sb.String()
939}
940
941func renderPost(id uint64) string {
942	p, ok := getPost(id)
943	if !ok {
944		return "# 404\nPost not found"
945	}
946	// Same suppression rule as channels_v2: the direct path must not leak
947	// hidden/deleted content.
948	if p.Hidden || p.Deleted {
949		return "# Post unavailable\n\n*This post has been hidden or removed.*\n"
950	}
951
952	var sb strings.Builder
953	sb.WriteString(ufmt.Sprintf("# Post %d\n\n", p.ID))
954	renderPostLine(&sb, p)
955
956	// Newest reply window only (fixed size); full thread = JSON cursor API.
957	rc := getReplyCount(p.ID)
958	if rc > 0 {
959		sb.WriteString("## Replies\n\n")
960		if rc > FeedPageSize {
961			sb.WriteString(ufmt.Sprintf("*Showing the %d newest of %d — full thread via ListRepliesJSON or the app.*\n\n", FeedPageSize, rc))
962		}
963		ids := collectPage(byParent, padID(p.ID)+":", padID(p.ID)+":\xff", 1)
964		// collectPage walks reverse (newest first); show oldest→newest.
965		for i := len(ids) - 1; i >= 0; i-- {
966			if r, ok := getPost(ids[i]); ok {
967				renderPostLine(&sb, r)
968			}
969		}
970	}
971	return sb.String()
972}
973
974// ── Shared render helpers (ported verbatim from channels_v2) ─
975
976func truncAddr(addr address) string {
977	s := string(addr)
978	if len(s) > 13 {
979		return s[:10] + "..."
980	}
981	return s
982}
983
984// sanitizeForRender strips markdown-sensitive characters to prevent injection.
985func sanitizeForRender(s string) string {
986	var out strings.Builder
987	for _, c := range s {
988		switch c {
989		case '[', ']', '(', ')', '#', '*', '`', '!', '<', '>', '|', '\\', '_', '~', '\n', '\r', '\t':
990			continue
991		default:
992			out.WriteRune(c)
993		}
994	}
995	return out.String()
996}