@@ -99,12 +99,6 @@ maxSendRatePerKey = 20
9999-- Window for the per-key send rate limit (1 s).
100100sendRateWindowMs : Int<Ms>
101101sendRateWindowMs = 1000
102−-- Receive/poll requests permitted per key per window.
103−maxRecvRatePerKey : Int
104−maxRecvRatePerKey = 10
105−-- Window for the per-key receive rate limit (1 s).
106−recvRateWindowMs : Int<Ms>
107−recvRateWindowMs = 1000
108102-- Requests permitted per source IP per window (covers unauthenticated traffic).
109103maxIpRatePerIp : Int
110104maxIpRatePerIp = 10
@@ -268,8 +262,6 @@ type RateEntry = {key: Text, windowStart: Timestamp, count: Int}
268262
269263-- Per-key fixed-window counters for /messages send-side rate limiting.
270264*sendRates : [RateEntry]
271−-- Per-recipient counters; protects a popular key from being flooded by many senders.
272−*recvRates : [RateEntry]
273265-- Per-key /auth/challenge counters (separate from `*challenges` capacity gate).
274266*challengeRates : [RateEntry]
275267-- Per-source-IP counters; gate for unauthenticated traffic.
@@ -470,7 +462,7 @@ isBadServerName = \sn -> sn == "" || sn == "localhost" || not all (chars sn) (\c
470462-- Bearer token extraction, session lookup, and the `gateAuth` combinator that
471463-- every client handler funnels through (global rate → session).
472464-- Federation requests are unauthenticated: anyone can hit /federation/*.
473−-- Per-recipient `recvRate` and `no_presence` checks bound the spam ceiling.
465+-- The `no_presence` check bounds federated forwards to currently-online keys.
474466
475467-- Plain ip match (sessions with empty ip skip the check).
476468matchIp : Text -> Text -> Bool
@@ -913,7 +905,7 @@ pollOnce = \authorization cursor clientIp t -> do
913905-- some other transaction commits a change to `*pollWakeup`. Because
914906-- `pollHeartbeat` bumps `*pollWakeup` every `pollTimeoutMs`, no caller waits
915907-- longer than one heartbeat period before getting at least an empty response.
916−doPoll : {clampedCursor: Int, pubkey: PubkeyHex, startWakeup: Timestamp} -> Int<Ms> -> IO {r *pollWakeup, clock} {cursor: Int<Ms>, events: [{encryptedBlob: BlobHex}]}
908+doPoll : {clampedCursor: Int, pubkey: PubkeyHex, startWakeup: Timestamp | a} -> Int<Ms> -> IO {r *pollWakeup, clock} {cursor: Int<Ms>, events: [{encryptedBlob: BlobHex}]}
917909doPoll = \s t -> do
918910 let collectAt = \tnow -> atomic (do
919911 msgs <- *messages
@@ -954,17 +946,17 @@ ackPolledMessages = \pubkey cursor -> do
954946-- either succeeds (every message enqueued) or fails with a non-2xx HTTP
955947-- status carrying the rejection code ("rate_limited" | "self_send" | ...).
956948-- Duplicates are silently treated as success so retries are idempotent.
957−-- Send is gated by global rate and the per-sender / per-pair / per-recipient
958−-- rate limits charged together inside `checkSendRate`.
949+-- Send is gated by global rate and the per-sender rate limit charged inside
950+-- `checkSendRate`.
959951-- Federated forwards are kicked off in background forks after the atomic.
960952
961953-- POST /messages: send a list of ciphertexts atomically. Every message is
962−-- validated (self-send + send/recv rate windows) inside a single atomic
954+-- validated (self-send + per-sender rate window) inside a single atomic
963955-- block; if any one would be rejected, none are enqueued and the call returns
964−-- a non-2xx HTTP status. Rate counters are still charged for every attempted
965−-- message even when the batch is rejected, so a sender can't probe limits
966−-- cheaply (e.g. by sending to themselves). Federation forwards are kicked
967−-- per message after commit.
956+-- a non-2xx HTTP status. The send-rate counter is still charged for every
957+-- attempted message even when the batch is rejected, so a sender can't probe
958+-- limits cheaply (e.g. by sending to themselves). Federation forwards are
959+-- kicked per message after commit.
968960handleSendMessages : [{encryptedBlob: BlobHex, to: PubkeyHex | a}] -> Text -> Text -> IO {rw *globalRateCount, rw *globalRateWindowStart, r *sessions, clock} (Result HttpError {})
969961handleSendMessages = \messages authorization clientIp -> do
970962 t <- now
@@ -987,23 +979,20 @@ handleSendMessages = \messages authorization clientIp -> do
987979 when federationEnabled (forEach checked (\c -> fork (forkForwardsTo c.to c.encryptedBlob c.remoteServers)))
988980 yield (Ok {value: {}}))
989981
990−-- Charge send/recv rate counters for one (sender, recipient) pair and
991−-- decide whether the message would be accepted. Mutates only the rate
992−-- counters, never the mailbox; the caller commits via `commitSendMessage`
993−-- only after every message in the batch has passed.
994−-- Both rate counters are charged unconditionally (even on a failure)
995−-- so that a sender can't probe limits cheaply by sending to themselves.
996−checkSendRate : PubkeyHex -> PubkeyHex -> Int<Ms> -> IO {r *presence, rw *recvRates, rw *sendRates} {remoteServers: [Text], error: Text}
982+-- Charge the send rate counter for the sender and decide whether the message
983+-- would be accepted. Mutates only the rate counter, never the mailbox; the
984+-- caller commits via `commitSendMessage` only after every message in the
985+-- batch has passed. The counter is charged unconditionally (even on a
986+-- failure) so that a sender can't probe limits cheaply by sending to
987+-- themselves.
988+checkSendRate : PubkeyHex -> PubkeyHex -> Int<Ms> -> IO {r *presence, rw *sendRates} {remoteServers: [Text], error: Text}
997989checkSendRate = \senderKey to t -> do
998990 sRates <- *sendRates
999991 let sR = checkRate sRates senderKey maxSendRatePerKey sendRateWindowMs t
1000992 *sendRates = sR.entries
1001− rRates <- *recvRates
1002− let rR = checkRate rRates to maxRecvRatePerKey recvRateWindowMs t
1003− *recvRates = rR.entries
1004993 if senderKey == to
1005994 then yield {error: "self_send", remoteServers: []}
1006− else if sR.limited || rR.limited
995+ else if sR.limited
1007996 then yield {error: "rate_limited", remoteServers: []}
1008997 else do
1009998 pres <- *presence
@@ -1070,8 +1059,8 @@ forkForwardsTo = \to encryptedBlob servers -> forEach servers (\server -> do
10701059-- Handler: federation receive
10711060-- ============================================
10721061-- Inbound peer-to-peer endpoints. Federation is open and unauthenticated:
1073−-- the per-recipient `recvRate` and `no_presence` checks in `storeFederated`
1074−-- do the actual spam-bounding.
1062+-- the `no_presence` check in `storeFederated` bounds forwards to currently-
1063+-- online local keys.
10751064
10761065-- POST /federation/gossip: ingest peer presence events. The transport-level
10771066-- `fromServer` is the home server for every event. Drops every (pubkey,
@@ -1107,43 +1096,37 @@ handleRecvGossip = \events fromServer -> do
11071096 yield (Ok {value: {}})
11081097
11091098-- Store a federated/forwarded message into the local mailbox. Caller wraps in `atomic`.
1110−-- Returns "" on success, otherwise an error code: "no_presence" | "rate_limited".
1099+-- Returns "" on success, otherwise the error code "no_presence".
11111100--
11121101-- The presence gate requires the recipient to be **currently locally online**
11131102-- (not just visible to gossip somewhere). Without that, an A → B forward based
11141103-- on stale gossip about R could land on B with no actively-polling client and
11151104-- get stuck. If B refuses, A retries via `retryPendingForwards` against the
11161105-- next presence target.
1117−storeFederated : PubkeyHex -> BlobHex -> Int<Ms> -> IO {rw *messages, rw *pollWakeup, r *presence, rw *recvRates, rw *seqCounter} Text
1106+storeFederated : PubkeyHex -> BlobHex -> Int<Ms> -> IO {rw *messages, rw *pollWakeup, r *presence, rw *seqCounter} Text
11181107storeFederated = \toKey encryptedBlob t -> do
11191108 pres <- *presence
11201109 msgs <- *messages
11211110 if not any pres (\p -> p.pubkey == toKey && isLocalServer p.server && p.expiresAt > t)
11221111 then yield "no_presence"
11231112 else do
1124− rRates <- *recvRates
1125− let r = checkRate rRates toKey maxRecvRatePerKey recvRateWindowMs t
1126− *recvRates = r.entries
1127− if r.limited
1128− then yield "rate_limited"
1129− else do
1130− seq <- *seqCounter
1131− let seqTs = nextSeqTs t seq
1132− *seqCounter = seqTs
1133− *messages = union msgs [{toKey, encryptedBlob, receivedAt: seqTs}]
1134− *pollWakeup = seqTs
1135− yield ""
1113+ seq <- *seqCounter
1114+ let seqTs = nextSeqTs t seq
1115+ *seqCounter = seqTs
1116+ *messages = union msgs [{toKey, encryptedBlob, receivedAt: seqTs}]
1117+ *pollWakeup = seqTs
1118+ yield ""
11361119
11371120-- POST /federation/forward: receive a single federated message destined for
11381121-- a local key. Delegates the actual store decision to `storeFederated`,
11391122-- which returns "" on success.
1140−handleForwardMessage : PubkeyHex -> BlobHex -> IO {rw *messages, rw *pollWakeup, r *presence, rw *recvRates, rw *seqCounter, clock} (Result HttpError {})
1123+handleForwardMessage : PubkeyHex -> BlobHex -> IO {rw *messages, rw *pollWakeup, r *presence, rw *seqCounter, clock} (Result HttpError {})
11411124handleForwardMessage = \toKey encryptedBlob -> do
11421125 t <- now
11431126 result <- atomic storeFederated toKey encryptedBlob t
11441127 if result == ""
11451128 then yield (Ok {value: {}})
1146− else yield (Err {error: {status: if result == "no_presence" then 404 else 429, message: result}})
1129+ else yield (Err {error: {status: 404, message: result}})
11471130
11481131-- ============================================
11491132-- Dispatch