Phase A (the previous commit) added the on-chain foundations. Phase B
is the off-chain layer: post bodies live in a BadgerDB-backed feed
mailbox, and a full HTTP surface makes the feed usable from clients.
New components
relay/feed_mailbox.go (+ tests)
- FeedPost: body + content-type + attachment + hashtags + thread refs
- Store / Get / Delete with TTL-bounded eviction (30 days default)
- View counter (IncrementView / ViewCount) — off-chain because one
tx per view would be nonsense
- Hashtag inverted index: auto-extracts #tokens from content on
Store, lowercased + deduped + capped at 8/post
- Author chrono index: PostsByAuthor returns newest-first IDs
- RecentPostIDs: scan-by-age helper used by trending/foryou
node/api_feed.go
POST /feed/publish — author-signed body upload, returns
post_id + content_hash + size +
hashtags + estimated fee for the
follow-up on-chain CREATE_POST tx
GET /feed/post/{id} — fetch body (respects on-chain soft
delete, returns 410 when deleted)
GET /feed/post/{id}/stats — {views, likes, liked_by_me?}
POST /feed/post/{id}/view — bump the counter
GET /feed/author/{pub} — chain-authoritative post list
enriched with body + stats
GET /feed/timeline — merged feed from people the user
follows (reads chain.Following,
fetches each author's recent posts)
GET /feed/trending — top-scored posts in last 24h
(score = likes × 3 + views)
GET /feed/foryou — simple recommendations: recent posts
minus authors the user already
follows, already-liked posts, and
own posts; ranked by engagement
GET /feed/hashtag/{tag} — posts tagged with the given #tag
cmd/node/main.go wiring
- --feed-db flag (DCHAIN_FEED_DB) + --feed-ttl-days (DCHAIN_FEED_TTL_DAYS)
- Opens FeedMailbox + registers FeedRoutes alongside RelayRoutes
- Threads chain.Post / LikeCount / HasLiked / PostsByAuthor / Following
into FeedConfig so HTTP handlers can merge on-chain metadata with
off-chain body+stats.
Auth & safety
- POST /feed/publish: Ed25519 signature over "publish:<post_id>:
<content_sha256_hex>:<ts>"; ±5-minute skew window for anti-replay.
- content_hash binds body to the on-chain tx — you can't publish
body-A off-chain and commit hash-of-body-B on-chain.
- Writes wrapped in withSubmitTxGuards (rate-limit + size cap), reads
in withReadLimit — same guards as /relay.
Trending / recommendations
- V1 heuristic (likes × 3 + views) + time window. Documented as
v2.2.0 "Feed algorithm" candidate for a proper ranking layer
(half-life decay, follow-of-follow boost, hashtag collaborative).
Tests
- Store round-trip, size enforcement, hashtag indexing (case-insensitive
+ dedup), view counter increments, author chrono order, delete
cleans all indices, RecentPostIDs time-window filter.
- Full go test ./... is green (blockchain + consensus + identity +
relay + vm all pass).
Next (Phase C): client Feed tab — composer, timeline, post detail,
profile follow, For You + Trending screens.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the endpoint accepted an unauthenticated DELETE with just
?pub=X — anyone who knew (or enumerated) a pub could wipe that pub's
entire inbox, a trivial griefing vector. Now the handler requires a
JSON body with {ed25519_pub, sig, ts} where sig signs
"inbox-delete:<envID>:<pub>:<ts>" under the Ed25519 privkey. The
server then looks up the identity on-chain and verifies that the
registered X25519 public key matches the ?pub= query — closing the
gap between "I can sign" and "my identity owns this mailbox."
Timestamp window: ±300s to prevent replay of captured DELETEs.
Wires RelayConfig.ResolveX25519 via chain.Identity() in cmd/node/main.go.
When ResolveX25519 is nil the endpoint returns 503 (feature unavailable)
rather than silently allowing anonymous deletes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The WS topic-auth check had a soft-fail fallback: if the authenticated
identity had no registered X25519 public key (authX == ""), the
topic-ownership check was skipped and the client could subscribe to
any inbox:* or typing:* topic. Exploit: register an Ed25519 identity
without an X25519 key, subscribe to the victim's inbox topic, receive
their envelope notifications.
Now both topics hard-require a registered X25519. Clients must call
REGISTER_KEY (publishing X25519) before subscribing. The scope is
narrow — only identities that haven't completed REGISTER_KEY yet could
have exploited this — but a hard fail is still correct.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Relay routes were not wrapped in any guards — /relay/broadcast accepted
unlimited writes from any IP, and /relay/inbox could be scraped at line
rate. Combined with the per-recipient FIFO eviction (MailboxPerRecipientCap=500),
an unauthenticated attacker could wipe a victim's real messages by
spamming 500 garbage envelopes. This commit wraps writes in
withSubmitTxGuards (10/s per IP + 256 KiB body cap) and reads in
withReadLimit (20/s per IP) — the same limits already used for
/api/tx and /api/address.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The mailbox previously trusted the client-supplied envelope ID and SentAt,
which enabled two attacks:
- replay via re-broadcast: a malicious relay could resubmit the same
ciphertext under multiple IDs, causing the recipient to receive the
same plaintext repeatedly;
- timestamp spoofing: senders could back-date or future-date messages
to bypass the 7-day TTL or fake chronology.
Store() now recomputes env.ID as hex(sha256(nonce||ct)[:16]) and
overwrites env.SentAt with time.Now().Unix(). Both values are mutated
on the envelope pointer so downstream gossipsub publishes agree on the
normalised form.
Also documents /relay/send as non-E2E — the endpoint seals with the
relay's own key, which breaks end-to-end authenticity. Clients wanting
real E2E should POST /relay/broadcast with a pre-sealed envelope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>