@@ -112,6 +112,30 @@ backgroundPruneInterval = 30000
112112pollTimeoutMs : Int<Ms>
113113pollTimeoutMs = 25000
114114
115+-- Rolling window for every per-endpoint rate-limit bucket (1 min). All routes
116+-- share this window; only the per-window request budget varies per endpoint.
117+rateLimitWindow : Int<Ms>
118+rateLimitWindow = 60000
119+-- Per-pubkey budget for /auth/challenge and /auth/verify per `rateLimitWindow`.
120+-- The normal handshake is one challenge + one verify, so a healthy flow uses 2.
121+authRateLimit : Int
122+authRateLimit = 10
123+-- Per-session (bearer token) budget for /poll per `rateLimitWindow`. Clients
124+-- reconnect after `pollTimeoutMs`, so steady state is ~3/min.
125+pollRateLimit : Int
126+pollRateLimit = 60
127+-- Per-session budget for /messages per `rateLimitWindow`. Each call may carry
128+-- up to `maxBatchSize` ciphertexts, so the effective message ceiling is higher.
129+sendRateLimit : Int
130+sendRateLimit = 60
131+-- Per-peer-IP budget for /federation/gossip per `rateLimitWindow`.
132+gossipRateLimit : Int
133+gossipRateLimit = 60
134+-- Per-peer-IP budget for /federation/forward per `rateLimitWindow`. Higher than
135+-- gossip because peers fan out one call per buffered ciphertext.
136+forwardRateLimit : Int
137+forwardRateLimit = 600
138+
115139-- Master switch for federation; when False the server runs in single-node mode.
116140federationEnabled : Bool
117141federationEnabled = True {}
@@ -228,6 +252,27 @@ type ForwardFailure = {server: Text, failedAt: Timestamp, failures: Int}
228252-- Strictly-monotonic sequence used as `Message.receivedAt`; guarantees poll-cursor ordering.
229253*seqCounter : Timestamp
230254
255+-- ============================================
256+-- Rate limiting
257+-- ============================================
258+-- Per-endpoint token-bucket configuration. Auth endpoints key on the submitted
259+-- pubkey (so one keypair can't drain global capacity); authenticated client
260+-- endpoints key on the bearer token (per-session); federation endpoints key on
261+-- the connection IP. Each route has an independent bucket store, so endpoints
262+-- using the same key strategy don't share budget across routes. Buckets persist
263+-- in the runtime's `_knot_rate_limits` SQLite table; rejection responds 429
264+-- before the handler runs.
265+
266+byPubkey = \{pubkey} _ -> Just {value: pubkey}
267+byAuth = \{authorization} _ -> Just {value: authorization}
268+byClientIp = \_ ctx -> Just {value: ctx.clientIp}
269+
270+authLimit = {key: byPubkey, limit: {requests: authRateLimit, window: rateLimitWindow}}
271+pollLimit = {key: byAuth, limit: {requests: pollRateLimit, window: rateLimitWindow}}
272+sendLimit = {key: byAuth, limit: {requests: sendRateLimit, window: rateLimitWindow}}
273+gossipLimit = {key: byClientIp, limit: {requests: gossipRateLimit, window: rateLimitWindow}}
274+forwardLimit = {key: byClientIp, limit: {requests: forwardRateLimit, window: rateLimitWindow}}
275+
231276-- ============================================
232277-- Routes
233278-- ============================================
@@ -240,16 +285,16 @@ type ForwardFailure = {server: Text, failedAt: Timestamp, failures: Int}
240285-- `{"error": "<code>"}`.
241286
242287route AuthApi where
243− POST {pubkey: PubkeyHex} /auth/challenge headers {xForwardedFor: Maybe Text} -> {challenge: Text, expiresAt: Timestamp} = ReqChallenge
244− POST {pubkey: PubkeyHex, challenge: Text, signature: SignatureHex, revokeOthers: Bool} /auth/verify headers {xForwardedFor: Maybe Text} -> {token: Text, expiresAt: Timestamp} = VerifyAuth
288+ POST {pubkey: PubkeyHex} /auth/challenge headers {xForwardedFor: Maybe Text} -> {challenge: Text, expiresAt: Timestamp} rateLimit authLimit = ReqChallenge
289+ POST {pubkey: PubkeyHex, challenge: Text, signature: SignatureHex, revokeOthers: Bool} /auth/verify headers {xForwardedFor: Maybe Text} -> {token: Text, expiresAt: Timestamp} rateLimit authLimit = VerifyAuth
245290
246291route ClientApi where
247− POST {cursor: Timestamp} /poll headers {authorization: Text, xForwardedFor: Maybe Text} -> {events: [PollEvent], cursor: Timestamp} = Poll
248− POST {messages: [{to: PubkeyHex, encryptedBlob: BlobHex}]} /messages headers {authorization: Text, xForwardedFor: Maybe Text} -> {} = SendMessages
292+ POST {cursor: Timestamp} /poll headers {authorization: Text, xForwardedFor: Maybe Text} -> {events: [PollEvent], cursor: Timestamp} rateLimit pollLimit = Poll
293+ POST {messages: [{to: PubkeyHex, encryptedBlob: BlobHex}]} /messages headers {authorization: Text, xForwardedFor: Maybe Text} -> {} rateLimit sendLimit = SendMessages
249294
250295route FederationApi where
251− POST {events: [GossipEventType], fromServer: ServerName} /federation/gossip -> {} = RecvGossip
252− POST {toKey: PubkeyHex, encryptedBlob: BlobHex} /federation/forward -> {} = ForwardMessage
296+ POST {events: [GossipEventType], fromServer: ServerName} /federation/gossip -> {} rateLimit gossipLimit = RecvGossip
297+ POST {toKey: PubkeyHex, encryptedBlob: BlobHex} /federation/forward -> {} rateLimit forwardLimit = ForwardMessage
253298
254299route Api = AuthApi | ClientApi | FederationApi
255300
@@ -385,7 +430,9 @@ recordFailure = \server -> do
385430 let prevCount = case findFirst failures (\f -> f.server == server) of
386431 Just {value: f} -> f.failures
387432 _ -> 0
388− *forwardFailures = union (filter (\f -> f.server != server) failures) [{server, failedAt: t, failures: prevCount + 1}])
433+ *forwardFailures = union (filter (\f -> f.server != server) failures) [
434+ {server, failedAt: t, failures: prevCount + 1}
435+ ])
389436
390437-- Drop the failure record for `server` (called after any successful peer call).
391438-- Single-success recovery — there is no half-open state.