on master help sign in
Implement 16 server.knot improvements: security (double encoding check, stricter relay origin, DNS blocking), correctness (presence version bump, forward TTL fix), performance (union optimization, count-only stats, atomic gossip merge), operational (secret rotation, error codes, pagination)
987cacdiko
server.knot+220−111
@@ -20,7 +20,24 @@ listenPort = 8080
20-- transition window, so operators can rotate without restarting. E.g., add a
20-- transition window, so operators can rotate without restarting. E.g., add a
21-- maintenanceSecretPrevious field that's accepted for a configurable overlap period.
21-- maintenanceSecretPrevious field that's accepted for a configurable overlap period.
22maintenanceSecret = "" -- set to enable /maintenance/* and /stats endpoints
22maintenanceSecret = "" -- set to enable /maintenance/* and /stats endpoints
23+-- Secret rotation: when non-empty, both maintenanceSecret and maintenanceSecretPrevious
24+-- are accepted. To rotate: (1) set maintenanceSecretPrevious = current secret,
25+-- (2) set maintenanceSecret = new secret, (3) redeploy, (4) after all clients update,
26+-- clear maintenanceSecretPrevious and redeploy.
27+maintenanceSecretPrevious = ""
23federationScheme = "https://" -- scheme for federation HTTP calls
28federationScheme = "https://" -- scheme for federation HTTP calls
29+-- Optional pre-shared key for federation auth. When non-empty and a peer's server name
30+-- matches, the key is used instead of callback verification — eliminates the verify
31+-- round-trip for trusted peers. Format: "server1:key1,server2:key2".
32+-- TODO: Implement pre-shared key parsing and lookup. Currently callback verification
33+-- is always used. Pre-shared keys would halve federation latency for configured peers
34+-- and remove the /federation/verify endpoint as an attack surface for those peers.
35+federationPresharedKeys = ""
36+-- NOTE: listenHost is not configurable — Knot's listen primitive only accepts a port.
37+-- To restrict the bind address (e.g., 127.0.0.1 behind a reverse proxy), use
38+-- firewall rules (e.g., iptables -A INPUT -p tcp --dport 8080 -s 127.0.0.1 -j ACCEPT)
39+-- or a container network namespace.
40+-- TODO: If Knot adds a listen-with-host API, add listenHost = "0.0.0.0" config here.
24-- NOTE: Outbound federation HTTP calls have no runtime-level timeout. The fetchFederation
41-- NOTE: Outbound federation HTTP calls have no runtime-level timeout. The fetchFederation
25-- wrapper tracks in-flight requests per server and skips servers with outstanding requests
42-- wrapper tracks in-flight requests per server and skips servers with outstanding requests
26-- older than federationFetchTimeoutMs (see shouldRetryServerEx). This prevents a single
43-- older than federationFetchTimeoutMs (see shouldRetryServerEx). This prevents a single
@@ -796,7 +813,7 @@ route ClientApi where
796 -- Clamped to [minMessageTtl, unackedMessageMaxAge]. Short TTLs for ephemeral data
813 -- Clamped to [minMessageTtl, unackedMessageMaxAge]. Short TTLs for ephemeral data
797 -- (typing indicators, read receipts) free mailbox slots faster than the default 7 days.
814 -- (typing indicators, read receipts) free mailbox slots faster than the default 7 days.
798 POST {to: PubkeyHex, encryptedBlob: BlobHex, timestamp: Timestamp, messageId: OptionalMessageId, messageTtl: Int} / headers {authorization: Text, clientIp: Text}
815 POST {to: PubkeyHex, encryptedBlob: BlobHex, timestamp: Timestamp, messageId: OptionalMessageId, messageTtl: Int} / headers {authorization: Text, clientIp: Text}
799− -> {status: MessageStatus, messageId: OptionalMessageId, retryAfterMs: Int}
816+ -> {status: MessageStatus, messageId: OptionalMessageId, retryAfterMs: Int, error: Text}
800 = SendMessage
817 = SendMessage
801 -- Check delivery status of a sent message (caller must know recipient to prove ownership)
818 -- Check delivery status of a sent message (caller must know recipient to prove ownership)
802 POST {to: PubkeyHex, messageId: MessageIdHex} /status headers {authorization: Text, clientIp: Text}
819 POST {to: PubkeyHex, messageId: MessageIdHex} /status headers {authorization: Text, clientIp: Text}
@@ -804,34 +821,35 @@ route ClientApi where
804 = MessageStatus
821 = MessageStatus
805 -- Explicitly acknowledge received messages by ID (alternative to cursor-based ack in poll)
822 -- Explicitly acknowledge received messages by ID (alternative to cursor-based ack in poll)
806 POST {messageIds: [MessageIdHex]} /ack headers {authorization: Text, clientIp: Text}
823 POST {messageIds: [MessageIdHex]} /ack headers {authorization: Text, clientIp: Text}
807− -> {ok: Bool, authorized: Bool, ackedCount: Int}
824+ -> {ok: Bool, authorized: Bool, ackedCount: Int, error: Text}
808 = AckMessages
825 = AckMessages
809 -- Send multiple messages in one request (auth checked once; each message rate-limited independently)
826 -- Send multiple messages in one request (auth checked once; each message rate-limited independently)
810 POST {messages: [BatchMessage]} /batch headers {authorization: Text, clientIp: Text}
827 POST {messages: [BatchMessage]} /batch headers {authorization: Text, clientIp: Text}
811 -> {results: [BatchResult], authorized: Bool, retryAfterMs: Int}
828 -> {results: [BatchResult], authorized: Bool, retryAfterMs: Int}
812 = SendBatch
829 = SendBatch
813− -- List pending messages without acking (useful after cursor loss or device migration)
830+ -- List pending messages without acking (useful after cursor loss or device migration).
831+ -- offset: skip the first N messages (for pagination, default 0).
814− GET /messages/list headers {authorization: Text, clientIp: Text}
832+ POST {offset: Int} /list headers {authorization: Text, clientIp: Text}
815− -> {messages: [{messageId: MessageIdHex, timestamp: Timestamp, receivedAt: Timestamp}], authorized: Bool, count: Int}
833+ -> {messages: [{messageId: MessageIdHex, timestamp: Timestamp, receivedAt: Timestamp}], authorized: Bool, count: Int, error: Text}
816 = ListMessages
834 = ListMessages
817 -- Delete pending messages by ID (recipient-only; messages don't store sender pubkey)
835 -- Delete pending messages by ID (recipient-only; messages don't store sender pubkey)
818 POST {messageIds: [MessageIdHex]} /delete headers {authorization: Text, clientIp: Text}
836 POST {messageIds: [MessageIdHex]} /delete headers {authorization: Text, clientIp: Text}
819− -> {ok: Bool, authorized: Bool, deletedCount: Int}
837+ -> {ok: Bool, authorized: Bool, deletedCount: Int, error: Text}
820 = DeleteMessages
838 = DeleteMessages
821 -- Cancel (retract) a sent message before the recipient reads it.
839 -- Cancel (retract) a sent message before the recipient reads it.
822 -- Caller provides messageId and recipient pubkey — knowing the messageId proves
840 -- Caller provides messageId and recipient pubkey — knowing the messageId proves
823 -- sender identity (either client-generated or returned only to the sender).
841 -- sender identity (either client-generated or returned only to the sender).
824 -- Removes from recipient mailbox, cancels pending forwards, sends federation acks.
842 -- Removes from recipient mailbox, cancels pending forwards, sends federation acks.
825 POST {messageId: MessageIdHex, to: PubkeyHex} /cancel headers {authorization: Text, clientIp: Text}
843 POST {messageId: MessageIdHex, to: PubkeyHex} /cancel headers {authorization: Text, clientIp: Text}
826− -> {ok: Bool, authorized: Bool, cancelled: Bool}
844+ -> {ok: Bool, authorized: Bool, cancelled: Bool, error: Text}
827 = CancelMessage
845 = CancelMessage
828 -- Look up which server a pubkey is present on (for federation routing)
846 -- Look up which server a pubkey is present on (for federation routing)
829 GET /lookup/{pubkey: PubkeyHex} headers {authorization: Text, clientIp: Text}
847 GET /lookup/{pubkey: PubkeyHex} headers {authorization: Text, clientIp: Text}
830− -> {server: AnyServerName, authorized: Bool}
848+ -> {server: AnyServerName, authorized: Bool, error: Text}
831 = LookupPresence
849 = LookupPresence
832 -- Batch lookup: look up presence for multiple pubkeys in one request
850 -- Batch lookup: look up presence for multiple pubkeys in one request
833 POST {pubkeys: [PubkeyHex]} /lookup/batch headers {authorization: Text, clientIp: Text}
851 POST {pubkeys: [PubkeyHex]} /lookup/batch headers {authorization: Text, clientIp: Text}
834− -> {results: [{pubkey: PubkeyHex, server: AnyServerName}], authorized: Bool}
852+ -> {results: [{pubkey: PubkeyHex, server: AnyServerName}], authorized: Bool, error: Text}
835 = BatchLookupPresence
853 = BatchLookupPresence
836
854
837-- Server-to-server federation API (authenticated via domain callback verification)
855-- Server-to-server federation API (authenticated via domain callback verification)
@@ -846,6 +864,11 @@ route FederationApi where
846 -- Receive a forwarded message from a peer server on behalf of a sender.
864 -- Receive a forwarded message from a peer server on behalf of a sender.
847 -- messageTtl: remaining TTL in ms (0 = default). Allows short-lived messages to
865 -- messageTtl: remaining TTL in ms (0 = default). Allows short-lived messages to
848 -- expire on the destination server without waiting for the full unackedMessageMaxAge.
866 -- expire on the destination server without waiting for the full unackedMessageMaxAge.
867+ -- NOTE: senderKey is intentionally NOT included in the forward protocol. The sender's
868+ -- identity is inside the E2E encrypted blob — exposing it in plaintext would break the
869+ -- privacy model (the server never learns who sent a message). /messages/cancel for
870+ -- federated messages requires the client to include sender identity in the encrypted
871+ -- payload; the server cannot verify sender identity for federation-forwarded messages.
849 POST {toKey: PubkeyHex, encryptedBlob: BlobHex, timestamp: Timestamp, originServer: ServerName, messageId: MessageIdHex, fromServer: ServerName, requestTimestamp: Timestamp, correlationId: Text, messageTtl: Int} /forward headers {authorization: Text}
872 POST {toKey: PubkeyHex, encryptedBlob: BlobHex, timestamp: Timestamp, originServer: ServerName, messageId: MessageIdHex, fromServer: ServerName, requestTimestamp: Timestamp, correlationId: Text, messageTtl: Int} /forward headers {authorization: Text}
850 -> {ok: Bool, error: Text}
873 -> {ok: Bool, error: Text}
851 = ForwardMessage
874 = ForwardMessage
@@ -959,9 +982,10 @@ route StatsApi where
959 GET /stats/key/{pubkey: PubkeyHex} headers {authorization: Text}
982 GET /stats/key/{pubkey: PubkeyHex} headers {authorization: Text}
960 -> {messages: Int, presence: [KeyPresenceEntry], sessions: Int, forwards: Int, forwardDetails: [{server: Text, retries: Int, createdAt: Timestamp}], activePolls: Int, ok: Bool}
983 -> {messages: Int, presence: [KeyPresenceEntry], sessions: Int, forwards: Int, forwardDetails: [{server: Text, retries: Int, createdAt: Timestamp}], activePolls: Int, ok: Bool}
961 = StatsKey
984 = StatsKey
962− -- Top recipients by pending message count (for abuse investigation — find inbox-bombing targets)
985+ -- Top recipients by pending message count (for abuse investigation — find inbox-bombing targets).
986+ -- limit: max recipients to return (default 20, capped at 100). 0 = default.
963− GET /stats/top-recipients headers {authorization: Text}
987+ POST {limit: Int} /stats/top-recipients headers {authorization: Text}
964− -> {recipients: [RecipientCount], ok: Bool}
988+ -> {recipients: [RecipientCount], ok: Bool, total: Int}
965 = TopRecipients
989 = TopRecipients
966
990
967-- Readiness probe: returns false during startup warmup (before first prune completes),
991-- Readiness probe: returns false during startup warmup (before first prune completes),
@@ -1099,6 +1123,15 @@ containsPercentEncoding = \s ->
1099 else if take 1 s == "%" && isHexChar (take 1 (drop 1 s)) && isHexChar (take 1 (drop 2 s)) then True {}
1123 else if take 1 s == "%" && isHexChar (take 1 (drop 1 s)) && isHexChar (take 1 (drop 2 s)) then True {}
1100 else containsPercentEncoding (drop 1 s)
1124 else containsPercentEncoding (drop 1 s)
1101
1125
1126+-- Detect double percent-encoding (%25XX -> %XX after one decode pass).
1127+-- If any proxy in the path decodes once, the second layer resolves to a private IP.
1128+-- Example: %2531%2532%2537.0.0.1 -> %31%32%37.0.0.1 -> 127.0.0.1
1129+containsDoubleEncoding : Text -> Bool
1130+containsDoubleEncoding = \s ->
1131+ if s == "" then False {}
1132+ else if take 3 s == "%25" then True {}
1133+ else containsDoubleEncoding (drop 1 s)
1134+
1102isHexStart : Text -> Bool
1135isHexStart : Text -> Bool
1103isHexStart = \s -> take 2 s == "0x" || take 2 s == "0X"
1136isHexStart = \s -> take 2 s == "0x" || take 2 s == "0X"
1104hasHexPrefix : Text -> Bool
1137hasHexPrefix : Text -> Bool
@@ -1224,6 +1257,16 @@ isBadServerName = \sn ->
1224 || containsSuffix ".example" sn
1257 || containsSuffix ".example" sn
1225 || containsSuffix ".invalid" sn
1258 || containsSuffix ".invalid" sn
1226 || containsPercentEncoding sn
1259 || containsPercentEncoding sn
1260+ || containsDoubleEncoding sn
1261+ -- Kubernetes / service mesh internal DNS
1262+ || containsSuffix ".svc" sn
1263+ -- HashiCorp Consul service discovery
1264+ || containsSuffix ".consul" sn
1265+ -- NOTE: Unicode/IDN defense is provided by containsBadUrlChar above, which rejects
1266+ -- any character not in [0-9a-z-.:] via isAllowedServerChar. This blocks homoglyph
1267+ -- attacks (e.g., Greek omicron in "lοcalhost") assuming Knot's Text type preserves
1268+ -- Unicode codepoints in take/contains operations. If Knot ever normalizes Unicode
1269+ -- (NFKC/NFC) before comparison, add an explicit ASCII-range byte check here.
1227
1270
1228-- Look up a non-expired session matching the given token (constant-time comparison).
1271-- Look up a non-expired session matching the given token (constant-time comparison).
1229-- Caller passes the appropriate session bucket (sharded by token prefix).
1272-- Caller passes the appropriate session bucket (sharded by token prefix).
@@ -1261,7 +1304,7 @@ verifyAuthWith = \check authorization ->
1261 Just {value: token} -> check token
1304 Just {value: token} -> check token
1262
1305
1263verifyMaintenanceAuth : Text -> Bool
1306verifyMaintenanceAuth : Text -> Bool
1264−verifyMaintenanceAuth = verifyAuthWith (\token -> verifySecret token maintenanceSecret)
1307+verifyMaintenanceAuth = verifyAuthWith (\token -> verifySecret token maintenanceSecret || (maintenanceSecretPrevious != "" && verifySecret token maintenanceSecretPrevious))
1265
1308
1266-- Check if a server is on the blocklist
1309-- Check if a server is on the blocklist
1267isBlockedPeer : Text -> [Text] -> Bool
1310isBlockedPeer : Text -> [Text] -> Bool
@@ -1884,11 +1927,13 @@ adjustRecipientCount = \counts pubkey delta ->
1884-- incrementing existing entries or inserting new ones.
1927-- incrementing existing entries or inserting new ones.
1885-- NOTE: O(n*k) where k = unique recipients due to `any acc` check inside fold.
1928-- NOTE: O(n*k) where k = unique recipients due to `any acc` check inside fold.
1886-- Acceptable for per-bucket data (n ≤ maxMessages/16). A map type would make this O(n).
1929-- Acceptable for per-bucket data (n ≤ maxMessages/16). A map type would make this O(n).
1930+-- NOTE: union [new] acc prepends (O(1) per step, O(n) total) vs union acc [new] which
1931+-- copies acc (O(|acc|) per step, O(n*k) total). Safe here because this is a set (order irrelevant).
1887countByRecipient = \items ->
1932countByRecipient = \items ->
1888 fold (\acc x ->
1933 fold (\acc x ->
1889 if any acc (\c -> c.pubkey == x.toKey)
1934 if any acc (\c -> c.pubkey == x.toKey)
1890 then do c <- acc; yield (if c.pubkey == x.toKey then {pubkey: c.pubkey, count: c.count + 1} else c)
1935 then do c <- acc; yield (if c.pubkey == x.toKey then {pubkey: c.pubkey, count: c.count + 1} else c)
1891− else union acc [{pubkey: x.toKey, count: 1}]) [] items
1936+ else union [{pubkey: x.toKey, count: 1}] acc) [] items
1892
1937
1893-- Batch-decrement recipient counts: subtract per-key removal counts in a single pass
1938-- Batch-decrement recipient counts: subtract per-key removal counts in a single pass
1894-- instead of calling adjustRecipientCount in a loop.
1939-- instead of calling adjustRecipientCount in a loop.
@@ -2298,6 +2343,15 @@ countAllAcked : Int -> IO Int
2298countAllAcked = \b -> if b > 15 then yield 0 else do
2343countAllAcked = \b -> if b > 15 then yield 0 else do
2299 bucket <- readAckedBucket b; rest <- countAllAcked (b + 1); yield (count bucket + rest)
2344 bucket <- readAckedBucket b; rest <- countAllAcked (b + 1); yield (count bucket + rest)
2300
2345
2346+-- Find the oldest (minimum) ackedAt timestamp across all 16 acked buckets.
2347+-- Returns 0 if no acked entries exist. Avoids materializing all buckets into one list.
2348+computeOldestAckedAt : Int -> IO Timestamp
2349+computeOldestAckedAt = \b -> if b > 15 then yield 0 else do
2350+ bucket <- readAckedBucket b
2351+ let bucketMin = case minBy bucket (\x -> x.ackedAt) of Nothing {} -> 0; Just {value: x} -> x.ackedAt
2352+ restMin <- computeOldestAckedAt (b + 1)
2353+ yield (if bucketMin == 0 then restMin else if restMin == 0 then bucketMin else min bucketMin restMin)
2354+
2301-- ============================================
2355-- ============================================
2302-- DeliveredIds Bucket Helpers
2356-- DeliveredIds Bucket Helpers
2303-- ============================================
2357-- ============================================
@@ -2531,7 +2585,7 @@ checkUniqueKeyIpRate = \entries ip pubkey t ->
2531 if alreadyTracked then {limited: False {}, entries: entries}
2585 if alreadyTracked then {limited: False {}, entries: entries}
2532 else if count r.keys >= maxUniqueKeysPerIp
2586 else if count r.keys >= maxUniqueKeysPerIp
2533 then {limited: True {}, entries: entries}
2587 then {limited: True {}, entries: entries}
2534− else {limited: False {}, entries: do e <- entries; yield (if e.ip == ip then {ip: e.ip, keys: union e.keys [pubkey], windowStart: e.windowStart} else e)}
2588+ else {limited: False {}, entries: do e <- entries; yield (if e.ip == ip then {ip: e.ip, keys: union [pubkey] e.keys, windowStart: e.windowStart} else e)}
2535
2589
2536checkServerRecipientRate = \rates server recipient maxRate windowMs maxEntries t ->
2590checkServerRecipientRate = \rates server recipient maxRate windowMs maxEntries t ->
2537 checkRateWindow rates (\r -> r.server == server && r.recipient == recipient)
2591 checkRateWindow rates (\r -> r.server == server && r.recipient == recipient)
@@ -2642,14 +2696,14 @@ collectPeerServers = \items getServer ->
2642samePubkeyServer = \a b -> a.pubkey == b.pubkey && a.server == b.server
2696samePubkeyServer = \a b -> a.pubkey == b.pubkey && a.server == b.server
2643pubkeyServer = \e -> {pubkey: e.pubkey, server: e.server}
2697pubkeyServer = \e -> {pubkey: e.pubkey, server: e.server}
2644
2698
2645−-- Upsert items by key, keeping the fresher entry on conflict (last-write-wins by freshness)
2699+-- Upsert items by key, keeping the fresher entry on conflict (last-write-wins by freshness).
2700+-- union [e] filtered prepends (O(1)) vs union filtered [e] which copies filtered (O(|acc|)).
2646mergeByKey = \sameKey getFreshness initial items ->
2701mergeByKey = \sameKey getFreshness initial items ->
2647 fold (\acc e ->
2702 fold (\acc e ->
2648 if any acc (\a -> sameKey a e && getFreshness a >= getFreshness e)
2703 if any acc (\a -> sameKey a e && getFreshness a >= getFreshness e)
2649 then acc
2704 then acc
2650− else union
2705+ else union [e]
2651− (do a <- acc; where not (sameKey a e); yield a)
2706+ (do a <- acc; where not (sameKey a e); yield a)) initial items
2652− [e]) initial items
2653
2707
2654-- Merge presence entries by (pubkey, server), keeping the one with the latest expiresAt
2708-- Merge presence entries by (pubkey, server), keeping the one with the latest expiresAt
2655mergeByPubkeyServer = mergeByKey samePubkeyServer (\x -> x.expiresAt)
2709mergeByPubkeyServer = mergeByKey samePubkeyServer (\x -> x.expiresAt)
@@ -2776,8 +2830,15 @@ pruneExpired = \t -> do
2776 set *challenges = do x <- challenges; where x.expiresAt > t; yield x
2830 set *challenges = do x <- challenges; where x.expiresAt > t; yield x
2777 -- Prune sessions and count in a single pass (avoids redundant 16-bucket traversal).
2831 -- Prune sessions and count in a single pass (avoids redundant 16-bucket traversal).
2778 -- pruneAndCountAllSessions applies the filter to each bucket and sums the survivors.
2832 -- pruneAndCountAllSessions applies the filter to each bucket and sums the survivors.
2833+ oldSessionCount <- *totalSessionCount
2779 sessionCount <- pruneAndCountAllSessions (\bucket -> do x <- bucket; where x.expiresAt > t && t - x.createdAt < maxSessionLifetime; yield x)
2834 sessionCount <- pruneAndCountAllSessions (\bucket -> do x <- bucket; where x.expiresAt > t && t - x.createdAt < maxSessionLifetime; yield x)
2835+ atomic do
2780− atomic do set *totalSessionCount = sessionCount
2836+ set *totalSessionCount = sessionCount
2837+ -- Bump presenceVersion when sessions are pruned: if a pubkey's last session expired,
2838+ -- its presence will be pruned in a subsequent cycle. Bumping here ensures polls
2839+ -- detect the transition sooner (within one prune interval) rather than waiting for
2840+ -- the presence entry's separate TTL to expire.
2841+ when (sessionCount < oldSessionCount) (set *presenceVersion = t)
2781 -- Prune messages across all 16 buckets concurrently. Each bucket prune is atomic
2842 -- Prune messages across all 16 buckets concurrently. Each bucket prune is atomic
2782 -- and touches only its own message bucket + rcBucket. The only shared write is
2843 -- and touches only its own message bucket + rcBucket. The only shared write is
2783 -- *totalMessageCount (a single Int), so STM contention is minimal.
2844 -- *totalMessageCount (a single Int), so STM contention is minimal.
@@ -2884,6 +2945,15 @@ pruneExpired = \t -> do
2884 yield presenceResult
2945 yield presenceResult
2885
2946
2886
2947
2948+-- Skip the first N items of a list (list equivalent of Text's drop).
2949+-- O(n) single pass: fold counts items and only starts accumulating after N.
2950+skipRelation = \n items ->
2951+ let r = fold (\acc e ->
2952+ if acc.skipped < n then {skipped: acc.skipped + 1, result: acc.result}
2953+ else {skipped: acc.skipped, result: union acc.result [e]})
2954+ {skipped: 0, result: []} items
2955+ in r.result
2956+
2887splitAtN = \items n ->
2957splitAtN = \items n ->
2888 fold (\acc e ->
2958 fold (\acc e ->
2889 if acc.n < n then {n: acc.n + 1, batch: union acc.batch [e], rest: acc.rest}
2959 if acc.n < n then {n: acc.n + 1, batch: union acc.batch [e], rest: acc.rest}
@@ -4069,16 +4139,33 @@ verifyAuthInner = \pubkey challenge signature revokeOthers clientIp respond t ->
4069 set *totalSessionCount = tsc + 1
4139 set *totalSessionCount = tsc + 1
4070 yield (Just {value: {peerServers: peerServers, revokedCount: 0}})
4140 yield (Just {value: {peerServers: peerServers, revokedCount: 0}})
4071 else do
4141 else do
4072− -- Slow path: revocation or capacity pressure — need all sessions
4142+ -- Slow path: revocation or capacity pressure.
4073− -- TODO: Iterate bucket-by-bucket to avoid materializing all 16 buckets.
4143+ -- Bucket-by-bucket approach: revoke sessions for this pubkey across all
4074− -- Currently O(total_sessions); could be O(sessions_in_bucket) for revoke.
4144+ -- 16 buckets without materializing them into one list. Then add the new
4075− allSessions <- readAllSessions
4145+ -- session to its bucket and handle global capacity if needed.
4076 let revokedCount = if revokeOthers then sessionsForKey else 0
4146 let revokedCount = if revokeOthers then sessionsForKey else 0
4077− let afterRevoke = if revokeOthers then do s <- allSessions; where s.pubkey != pubkey; yield s else allSessions
4147+ when revokeOthers
4078− let capped = capPerKeyAndGlobal afterRevoke (\s -> s.pubkey == pubkey) maxSessionsPerKey (\s -> s.expiresAt) t maxSessions
4148+ (transformAllSessionBuckets (\bucket -> do s <- bucket; where s.pubkey != pubkey; yield s))
4079− let sessionsWithNew = union capped [newSession]
4149+ -- Per-key cap: if still over maxSessionsPerKey (non-revoke case), evict oldest for this key
4150+ let nb = tokenBucket token
4151+ when (not revokeOthers && sessionsForKey >= maxSessionsPerKey)
4152+ (transformAllSessionBuckets (\bucket ->
4153+ let forKey = do s <- bucket; where s.pubkey == pubkey; yield s in
4154+ let others = do s <- bucket; where s.pubkey != pubkey; yield s in
4155+ if count forKey > 0 then union others (evictOneOldest forKey (\s -> s.expiresAt))
4156+ else bucket))
4157+ -- Global cap: if over maxSessions, evict oldest globally.
4158+ -- Must re-count after revocation/per-key cap to get accurate total.
4159+ currentTotal <- countAllSessions
4160+ when (currentTotal >= maxSessions)
4161+ (do -- Find bucket with oldest session and evict from it
4162+ allSess <- readAllSessions
4163+ let globalCapped = capGlobal allSess maxSessions (\s -> s.expiresAt)
4164+ writeAllSessions globalCapped)
4165+ currentBucket <- readSessionBucket nb
4166+ writeSessionBucket nb (union currentBucket [newSession])
4167+ finalCount <- countAllSessions
4080− set *totalSessionCount = count sessionsWithNew
4168+ set *totalSessionCount = finalCount
4081− writeAllSessions sessionsWithNew
4082 yield (Just {value: {peerServers: peerServers, revokedCount: revokedCount}})
4169 yield (Just {value: {peerServers: peerServers, revokedCount: revokedCount}})
4083 case authResult of
4170 case authResult of
4084 Nothing {} -> yield (respond {token: "", expiresAt: 0, error: "draining"})
4171 Nothing {} -> yield (respond {token: "", expiresAt: 0, error: "draining"})
@@ -4304,11 +4391,11 @@ sendBatchForwardsSequential = \msgs server ->
4304handleSendMessage = \to encryptedBlob timestamp messageId messageTtl authorization clientIp respond -> do
4391handleSendMessage = \to encryptedBlob timestamp messageId messageTtl authorization clientIp respond -> do
4305 t <- now
4392 t <- now
4306 globalRateLimited <- checkGlobalRate t
4393 globalRateLimited <- checkGlobalRate t
4307− if globalRateLimited then yield (respond {status: MsRateLimited {}, messageId: "", retryAfterMs: 0})
4394+ if globalRateLimited then yield (respond {status: MsRateLimited {}, messageId: "", retryAfterMs: 0, error: "rate_limited"})
4308 else do
4395 else do
4309 effectiveMsgId <- if messageId != "" then yield messageId else genToken
4396 effectiveMsgId <- if messageId != "" then yield messageId else genToken
4310 if t - timestamp > messageMaxAge
4397 if t - timestamp > messageMaxAge
4311− then yield (respond {status: MsRejected {}, messageId: "", retryAfterMs: 0})
4398+ then yield (respond {status: MsRejected {}, messageId: "", retryAfterMs: 0, error: "stale_timestamp"})
4312 else do
4399 else do
4313 when (timestamp > t + clockSkewTolerance)
4400 when (timestamp > t + clockSkewTolerance)
4314 (logWarn ("sendMessage: future timestamp detected (+" ++ show (timestamp - t) ++ "ms ahead), clamping to now"))
4401 (logWarn ("sendMessage: future timestamp detected (+" ++ show (timestamp - t) ++ "ms ahead), clamping to now"))
@@ -4327,30 +4414,30 @@ handleSendMessage = \to encryptedBlob timestamp messageId messageTtl authorizati
4327 _ -> yield {}
4414 _ -> yield {}
4328 forkSendForwards effectiveMsgId to encryptedBlob clampedTs result.msgExpiresAt result.remoteServers
4415 forkSendForwards effectiveMsgId to encryptedBlob clampedTs result.msgExpiresAt result.remoteServers
4329 yield (case result.status of
4416 yield (case result.status of
4330− MsDuplicate {} -> respond {status: MsDuplicate {}, messageId: effectiveMsgId, retryAfterMs: 0}
4417+ MsDuplicate {} -> respond {status: MsDuplicate {}, messageId: effectiveMsgId, retryAfterMs: 0, error: ""}
4331− MsDelivered {} -> respond {status: MsDelivered {}, messageId: effectiveMsgId, retryAfterMs: 0}
4418+ MsDelivered {} -> respond {status: MsDelivered {}, messageId: effectiveMsgId, retryAfterMs: 0, error: ""}
4332− MsFederated {} -> respond {status: MsFederated {}, messageId: effectiveMsgId, retryAfterMs: 0}
4419+ MsFederated {} -> respond {status: MsFederated {}, messageId: effectiveMsgId, retryAfterMs: 0, error: ""}
4333− MsQueued {} -> respond {status: MsQueued {}, messageId: effectiveMsgId, retryAfterMs: 0}
4420+ MsQueued {} -> respond {status: MsQueued {}, messageId: effectiveMsgId, retryAfterMs: 0, error: ""}
4334− _ -> respond {status: result.status, messageId: "", retryAfterMs: result.retryAfterMs})
4421+ _ -> respond {status: result.status, messageId: "", retryAfterMs: result.retryAfterMs, error: "rejected"})
4335
4422
4336-- GET /lookup/:pubkey: Look up the best known server for a pubkey.
4423-- GET /lookup/:pubkey: Look up the best known server for a pubkey.
4337-- Returns local server if key is online locally, otherwise the remote with latest expiry.
4424-- Returns local server if key is online locally, otherwise the remote with latest expiry.
4338handleLookupPresence = \pubkey authorization clientIp respond -> do
4425handleLookupPresence = \pubkey authorization clientIp respond -> do
4339 t <- now
4426 t <- now
4340 globalRateLimited <- checkGlobalRate t
4427 globalRateLimited <- checkGlobalRate t
4341− if globalRateLimited then yield (respond {server: "", authorized: False {}})
4428+ if globalRateLimited then yield (respond {server: "", authorized: False {}, error: "rate_limited"})
4342 else do
4429 else do
4343 result <- atomic do
4430 result <- atomic do
4344 dp <- *drainPhase
4431 dp <- *drainPhase
4345− if dp >= 2 then yield {server: "", authorized: False {}}
4432+ if dp >= 2 then yield {server: "", authorized: False {}, error: ""}
4346 else withSessionAuth authorization t clientIp
4433 else withSessionAuth authorization t clientIp
4347− (yield {server: "", authorized: False {}})
4434+ (yield {server: "", authorized: False {}, error: ""})
4348 (\senderKey -> do
4435 (\senderKey -> do
4349 lookupRates <- *lookupRates
4436 lookupRates <- *lookupRates
4350 let rateResult = checkPubkeyRate lookupRates senderKey maxLookupRatePerKey lookupRateWindowMs maxLookupRateEntries t
4437 let rateResult = checkPubkeyRate lookupRates senderKey maxLookupRatePerKey lookupRateWindowMs maxLookupRateEntries t
4351 set *lookupRates = rateResult.entries
4438 set *lookupRates = rateResult.entries
4352 if rateResult.limited
4439 if rateResult.limited
4353− then yield {server: "", authorized: True {}}
4440+ then yield {server: "", authorized: True {}, error: "rate_limited"}
4354 else do
4441 else do
4355 -- Hot path: read only the target key's presence bucket
4442 -- Hot path: read only the target key's presence bucket
4356 lookupBkt <- readPresenceBucket (pubkeyBucket pubkey)
4443 lookupBkt <- readPresenceBucket (pubkeyBucket pubkey)
@@ -4358,8 +4445,8 @@ handleLookupPresence = \pubkey authorization clientIp respond -> do
4358 -- Mask remote server names: clients only need to know local/remote/unknown,
4445 -- Mask remote server names: clients only need to know local/remote/unknown,
4359 -- not which specific peer server a key is homed on (prevents metadata leakage)
4446 -- not which specific peer server a key is homed on (prevents metadata leakage)
4360 let maskedServer = if foundServer == "" then "" else if isLocalServer foundServer then foundServer else "remote"
4447 let maskedServer = if foundServer == "" then "" else if isLocalServer foundServer then foundServer else "remote"
4361− yield {server: maskedServer, authorized: True {}})
4448+ yield {server: maskedServer, authorized: True {}, error: ""})
4362− yield (respond {server: result.server, authorized: result.authorized})
4449+ yield (respond {server: result.server, authorized: result.authorized, error: result.error})
4363
4450
4364-- POST /lookup/batch: Look up presence for multiple pubkeys in one request.
4451-- POST /lookup/batch: Look up presence for multiple pubkeys in one request.
4365-- Reads only the needed presence buckets (not all 16), reducing round trips
4452-- Reads only the needed presence buckets (not all 16), reducing round trips
@@ -4367,20 +4454,20 @@ handleLookupPresence = \pubkey authorization clientIp respond -> do
4367handleBatchLookupPresence = \pubkeys authorization clientIp respond -> do
4454handleBatchLookupPresence = \pubkeys authorization clientIp respond -> do
4368 t <- now
4455 t <- now
4369 globalRateLimited <- checkGlobalRate t
4456 globalRateLimited <- checkGlobalRate t
4370− if globalRateLimited then yield (respond {results: [], authorized: False {}})
4457+ if globalRateLimited then yield (respond {results: [], authorized: False {}, error: "rate_limited"})
4371 else do
4458 else do
4372 dp <- *drainPhase
4459 dp <- *drainPhase
4373− if dp >= 2 then yield (respond {results: [], authorized: False {}})
4460+ if dp >= 2 then yield (respond {results: [], authorized: False {}, error: ""})
4374 else
4461 else
4375 withSessionAuth authorization t clientIp
4462 withSessionAuth authorization t clientIp
4376− (yield (respond {results: [], authorized: False {}}))
4463+ (yield (respond {results: [], authorized: False {}, error: ""}))
4377 (\senderKey -> do
4464 (\senderKey -> do
4378 lookupRateLimited <- atomic do
4465 lookupRateLimited <- atomic do
4379 lookupRates <- *lookupRates
4466 lookupRates <- *lookupRates
4380 let rateResult = checkPubkeyRate lookupRates senderKey maxLookupRatePerKey lookupRateWindowMs maxLookupRateEntries t
4467 let rateResult = checkPubkeyRate lookupRates senderKey maxLookupRatePerKey lookupRateWindowMs maxLookupRateEntries t
4381 set *lookupRates = rateResult.entries
4468 set *lookupRates = rateResult.entries
4382 yield rateResult.limited
4469 yield rateResult.limited
4383− if lookupRateLimited then yield (respond {results: [], authorized: True {}})
4470+ if lookupRateLimited then yield (respond {results: [], authorized: True {}, error: "rate_limited"})
4384 else do
4471 else do
4385 let cappedKeys = takeRelation maxBatchLookupKeys pubkeys
4472 let cappedKeys = takeRelation maxBatchLookupKeys pubkeys
4386 result <- atomic do
4473 result <- atomic do
@@ -4390,7 +4477,7 @@ handleBatchLookupPresence = \pubkeys authorization clientIp respond -> do
4390 let foundServer = bestServerForKey (presenceForKey relevantPresence key t)
4477 let foundServer = bestServerForKey (presenceForKey relevantPresence key t)
4391 in let maskedServer = if foundServer == "" then "" else if isLocalServer foundServer then foundServer else "remote"
4478 in let maskedServer = if foundServer == "" then "" else if isLocalServer foundServer then foundServer else "remote"
4392 in {pubkey: key, server: maskedServer}))
4479 in {pubkey: key, server: maskedServer}))
4393− yield (respond {results: result, authorized: True {}}))
4480+ yield (respond {results: result, authorized: True {}, error: ""}))
4394
4481
4395-- POST /messages/status: Check delivery status of a previously sent message.
4482-- POST /messages/status: Check delivery status of a previously sent message.
4396-- Caller must provide both messageId and recipient pubkey (proves they know the recipient).
4483-- Caller must provide both messageId and recipient pubkey (proves they know the recipient).
@@ -4600,32 +4687,25 @@ handleRecvGossip = \events hopsRemaining fromServer requestTimestamp correlation
4600 let filteredOnline = resolveByTimestamp onlineEntries trustedOffline
4687 let filteredOnline = resolveByTimestamp onlineEntries trustedOffline
4601 let filteredOffline = resolveByTimestamp trustedOffline onlineEntries
4688 let filteredOffline = resolveByTimestamp trustedOffline onlineEntries
4602 let deduped = mergeByPubkeyServer [] filteredOnline
4689 let deduped = mergeByPubkeyServer [] filteredOnline
4603− -- Reject gossip entries that claim keys locally authenticated on this server.
4690+ -- Combined local-clash check + gossip merge in a single atomic block.
4604− -- A peer announcing presence for a key we have a live session for could be a presence
4691+ -- Previously these were two separate atomic blocks reading the same presence buckets;
4605− -- hijack attempt: the attacker's server would receive forwarded message copies.
4692+ -- merging them halves the bucket reads and avoids a TOCTOU gap between clash check and merge.
4606− -- Covers both first-party (e.server == fromServer) and third-party relay (e.server != fromServer)
4607− -- to prevent attackers from using relay gossip to reroute locally-authenticated keys.
4608− localClashes <- atomic do
4609− let targetBuckets = uniqueBucketsFor deduped (\e -> e.pubkey)
4610− localPresence <- readSelectedPresenceBuckets targetBuckets
4611− yield (do e <- deduped; where any localPresence (\p -> p.pubkey == e.pubkey && isLocalServer p.server && p.expiresAt > t); yield e.pubkey)
4612− when (count localClashes > 0)
4613− (logWarn ("gossip: " ++ fromServer ++ " claims " ++ show (count localClashes) ++ " locally-authenticated key(s) — filtering from merge"))
4614− -- Remove clashing entries from both deduped and trustedOffline before merge
4615− let safeDeduped = if count localClashes > 0
4616− then do d <- deduped; where not (any localClashes (\pk -> pk == d.pubkey)); yield d
4617− else deduped
4618− -- Per-bucket gossip merge: read only presence buckets affected by this gossip batch
4619− -- instead of all 16. All entries for a pubkey hash to the same bucket, so per-bucket
4693+ -- All entries for a pubkey hash to the same bucket, so per-bucket merge is complete.
4620− -- merge is equivalent to global merge. Reduces lock scope for small gossip batches.
4621− let safeTrustedOffline = if count localClashes > 0
4622− then do d <- trustedOffline; where not (any localClashes (\pk -> pk == d.pubkey)); yield d
4623− else trustedOffline
4624− let allGossipEntries = union safeDeduped safeTrustedOffline
4694+ let allGossipEntries = union deduped trustedOffline
4625 let affectedBuckets = uniqueBucketsFor allGossipEntries (\e -> e.pubkey)
4695 let affectedBuckets = uniqueBucketsFor allGossipEntries (\e -> e.pubkey)
4626 gossipResult <- atomic do
4696 gossipResult <- atomic do
4627 selectedPresence <- readSelectedPresenceBuckets affectedBuckets
4697 selectedPresence <- readSelectedPresenceBuckets affectedBuckets
4628 tpc <- *totalPresenceCount
4698 tpc <- *totalPresenceCount
4699+ -- Reject gossip entries that claim keys locally authenticated on this server.
4700+ -- A peer announcing presence for a key we have a live session for could be a presence
4701+ -- hijack attempt: the attacker's server would receive forwarded message copies.
4702+ let localClashes = do e <- deduped; where any selectedPresence (\p -> p.pubkey == e.pubkey && isLocalServer p.server && p.expiresAt > t); yield e.pubkey
4703+ let safeDeduped = if count localClashes > 0
4704+ then do d <- deduped; where not (any localClashes (\pk -> pk == d.pubkey)); yield d
4705+ else deduped
4706+ let safeTrustedOffline = if count localClashes > 0
4707+ then do d <- trustedOffline; where not (any localClashes (\pk -> pk == d.pubkey)); yield d
4708+ else trustedOffline
4629 -- Pre-filter: when near capacity, reject gossip entries for pubkeys not already in presence.
4709 -- Pre-filter: when near capacity, reject gossip entries for pubkeys not already in presence.
4630 -- This prevents a malicious peer from spiking presence above maxPresenceEntries via large batches.
4710 -- This prevents a malicious peer from spiking presence above maxPresenceEntries via large batches.
4631 let capacityDeduped = if tpc >= maxPresenceEntries
4711 let capacityDeduped = if tpc >= maxPresenceEntries
@@ -4641,7 +4721,9 @@ handleRecvGossip = \events hopsRemaining fromServer requestTimestamp correlation
4641 let newPeers = gm.gossipPeerServers
4721 let newPeers = gm.gossipPeerServers
4642 set *knownPeerServers = fold (\acc s -> if any acc (\a -> a == s) then acc else union acc [s]) knownPeers newPeers
4722 set *knownPeerServers = fold (\acc s -> if any acc (\a -> a == s) then acc else union acc [s]) knownPeers newPeers
4643 set *presenceVersion = t
4723 set *presenceVersion = t
4644− yield {gm: gm, preFiltered: count safeDeduped - count capacityDeduped}
4724+ yield {gm: gm, preFiltered: count safeDeduped - count capacityDeduped, localClashCount: count localClashes}
4725+ when (gossipResult.localClashCount > 0)
4726+ (logWarn ("gossip: " ++ fromServer ++ " claims " ++ show gossipResult.localClashCount ++ " locally-authenticated key(s) — filtered from merge"))
4645 let effectiveEvents = union (effectiveGossipEvents filteredOnline gossipResult.gm.appliedKeys (GossipOnline {})) (effectiveGossipEvents filteredOffline gossipResult.gm.appliedKeys (GossipOffline {}))
4727 let effectiveEvents = union (effectiveGossipEvents filteredOnline gossipResult.gm.appliedKeys (GossipOnline {})) (effectiveGossipEvents filteredOffline gossipResult.gm.appliedKeys (GossipOffline {}))
4646 when (not gossipResult.gm.withinCapacity || gossipResult.preFiltered > 0)
4728 when (not gossipResult.gm.withinCapacity || gossipResult.preFiltered > 0)
4647 (logWarn ("gossip: presence at capacity (" ++ show maxPresenceEntries ++ "), limiting new entries from " ++ fromServer ++ " (pre-filtered: " ++ show gossipResult.preFiltered ++ ")"))
4729 (logWarn ("gossip: presence at capacity (" ++ show maxPresenceEntries ++ "), limiting new entries from " ++ fromServer ++ " (pre-filtered: " ++ show gossipResult.preFiltered ++ ")"))
@@ -4684,15 +4766,24 @@ handleForwardMessage = \toKey encryptedBlob timestamp originServer messageId fro
4684 withFederationAuth authorization t respond fromServer "forward" requestTimestamp (not (isLocalServer originServer) && not (isLocalServer fromServer)) (True {}) (do
4766 withFederationAuth authorization t respond fromServer "forward" requestTimestamp (not (isLocalServer originServer) && not (isLocalServer fromServer)) (True {}) (do
4685 when (fromServer != originServer)
4767 when (fromServer != originServer)
4686 (logInfo ("forward: relay from " ++ fromServer ++ " (origin: " ++ originServer ++ ") for " ++ toKey))
4768 (logInfo ("forward: relay from " ++ fromServer ++ " (origin: " ++ originServer ++ ") for " ++ toKey))
4687− -- Relay origin verification: for relay forwards, verify originServer is a known peer
4769+ -- Relay origin verification: for relay forwards, verify:
4688− -- to prevent ack routing (and metadata leakage) to arbitrary servers.
4770+ -- 1. originServer is a known peer (prevents ack routing to arbitrary servers)
4771+ -- 2. originServer is not on the blocklist (blocked peers can't receive acks)
4772+ -- 3. originServer passes the allowlist if one is configured
4773+ -- This prevents metadata leakage: without these checks, a malicious peer B could
4774+ -- send a relay forward with originServer=A, causing us to send an ack (containing
4775+ -- messageId + recipient pubkey) to server A on B's behalf.
4689 originOk <- if fromServer == originServer then yield (True {})
4776 originOk <- if fromServer == originServer then yield (True {})
4690 else do
4777 else do
4691 knownPeers <- *knownPeerServers
4778 knownPeers <- *knownPeerServers
4779+ blocked <- *blockedPeers
4780+ allowed <- *allowedPeers
4692− yield (any knownPeers (\p -> p == originServer))
4781+ yield (any knownPeers (\p -> p == originServer)
4782+ && not (isBlockedPeer originServer blocked)
4783+ && isAllowedByAllowlist originServer allowed)
4693 if not originOk
4784 if not originOk
4694 then do
4785 then do
4695− logWarn ("forward: rejected relay with unknown origin " ++ originServer ++ " from " ++ fromServer)
4786+ logWarn ("forward: rejected relay with untrusted origin " ++ originServer ++ " from " ++ fromServer)
4696 yield (respond {ok: False {}, error: "unknown_origin"})
4787 yield (respond {ok: False {}, error: "unknown_origin"})
4697 else do
4788 else do
4698 -- Replay protection: reject forwards with timestamps older than messageMaxAge.
4789 -- Replay protection: reject forwards with timestamps older than messageMaxAge.
@@ -4752,7 +4843,12 @@ handleForwardMessage = \toKey encryptedBlob timestamp originServer messageId fro
4752 seqCounter <- *seqCounter
4843 seqCounter <- *seqCounter
4753 let seqTs = nextSeqTs t seqCounter
4844 let seqTs = nextSeqTs t seqCounter
4754 set *seqCounter = seqTs
4845 set *seqCounter = seqTs
4755− let fwdExpiresAt = computeMessageExpiresAt seqTs messageTtl
4846+ -- Federation forwards: use raw TTL without minMessageTtl clamp.
4847+ -- The TTL represents remaining lifetime from the origin server, which
4848+ -- may legitimately be less than minMessageTtl near expiry. Clamping
4849+ -- to minMessageTtl would extend the message's life on each hop.
4850+ let fwdExpiresAt = if messageTtl <= 0 then seqTs + unackedMessageMaxAge
4851+ else seqTs + clamp 1 unackedMessageMaxAge messageTtl
4756 writeMessageBucket bkt (union bucket
4852 writeMessageBucket bkt (union bucket
4757 [{messageId: messageId, toKey: toKey,
4853 [{messageId: messageId, toKey: toKey,
4758 encryptedBlob: encryptedBlob, timestamp: clampedTs, originServer: originServer, receivedAt: seqTs, expiresAt: fwdExpiresAt, senderKey: ""}])
4854 encryptedBlob: encryptedBlob, timestamp: clampedTs, originServer: originServer, receivedAt: seqTs, expiresAt: fwdExpiresAt, senderKey: ""}])
@@ -5080,9 +5176,11 @@ handleStats = \authorization respond -> do
5080 sc <- *totalSessionCount
5176 sc <- *totalSessionCount
5081 pc <- *totalPresenceCount
5177 pc <- *totalPresenceCount
5082 c <- *challenges
5178 c <- *challenges
5083− a <- readAllAcked
5179+ -- Count-only: avoids materializing all 16 acked/delivered buckets into one list
5180+ -- since only the count is needed for stats (not the entries themselves).
5181+ aCount <- countAllAcked 0
5084 ao <- *ackOverflow
5182 ao <- *ackOverflow
5085− d <- readAllDelivered
5183+ dCount <- countAllDelivered 0
5086 ff <- *forwardFailures
5184 ff <- *forwardFailures
5087 ap <- *activePolls
5185 ap <- *activePolls
5088 tmc <- *totalMessageCount
5186 tmc <- *totalMessageCount
@@ -5095,7 +5193,7 @@ handleStats = \authorization respond -> do
5095 ger <- *gossipEventRates
5193 ger <- *gossipEventRates
5096 vir <- *verifyIpRates
5194 vir <- *verifyIpRates
5097 ukir <- *uniqueKeyIpRates
5195 ukir <- *uniqueKeyIpRates
5098− yield {sc: sc, pc: pc, c: c, a: a, ao: ao, d: d, ff: ff, ap: ap, tmc: tmc, rc: rc, fi: fi, rg: rg, fnCount: fnCount, pac: pac, bp: bp, ger: ger, vir: vir, ukir: ukir}
5196+ yield {sc: sc, pc: pc, c: c, aCount: aCount, ao: ao, dCount: dCount, ff: ff, ap: ap, tmc: tmc, rc: rc, fi: fi, rg: rg, fnCount: fnCount, pac: pac, bp: bp, ger: ger, vir: vir, ukir: ukir}
5099 counters <- atomic do
5197 counters <- atomic do
5100 sr <- *sendRates
5198 sr <- *sendRates
5101 cr <- *challengeRates
5199 cr <- *challengeRates
@@ -5130,11 +5228,14 @@ handleStats = \authorization respond -> do
5130 let fedAvg = if counters.flc > 0 then counters.flt / counters.flc else 0
5228 let fedAvg = if counters.flc > 0 then counters.flt / counters.flc else 0
5131 let hungFedSlots = count (do r <- core.fi; where r.count > 0 && r.firstStartedAt > 0 && t - r.firstStartedAt >= federationFetchTimeoutMs; yield r)
5229 let hungFedSlots = count (do r <- core.fi; where r.count > 0 && r.firstStartedAt > 0 && t - r.firstStartedAt >= federationFetchTimeoutMs; yield r)
5132 let oldestFwdAge = fwdStats.oldestAge
5230 let oldestFwdAge = fwdStats.oldestAge
5133− let oldestAckAge = case minBy core.a (\x -> x.ackedAt) of Nothing {} -> 0; Just {value: x} -> t - x.ackedAt
5231+ -- Oldest ack age computed per-bucket without materializing all 16 buckets.
5232+ -- Scans one bucket at a time to find the global minimum ackedAt.
5233+ oldestAckTs <- computeOldestAckedAt 0
5234+ let oldestAckAge = if oldestAckTs == 0 then 0 else t - oldestAckTs
5134 let fwdServerCount = fwdStats.serverCount
5235 let fwdServerCount = fwdStats.serverCount
5135 let maxFwdDepth = fwdStats.maxDepth
5236 let maxFwdDepth = fwdStats.maxDepth
5136 yield (respond {ok: True {},
5237 yield (respond {ok: True {},
5137− capacity: {sessions: sessions, sessionsMax: maxSessions, sessionsPct: sessPct, messages: messages, messagesMax: maxMessages, messagesPct: msgPct, totalMessageCount: messages, presence: presence, presenceMax: maxPresenceEntries, presencePct: presPct, forwards: fwds, forwardsMax: maxForwardsEntries, forwardsPct: fwdPct, challenges: count core.c, challengesMax: maxChallenges, ackedIds: count core.a, ackedIdsMax: maxAckedIds, ackOverflow: count core.ao, ackOverflowMax: maxAckOverflow, deliveredIds: count core.d, deliveredIdsMax: maxDeliveredIds, forwardNonces: core.fnCount, forwardNoncesMax: maxForwardNonces, activePolls: count core.ap, activePollsMax: maxActivePolls, recipientCounts: count core.rc},
5238+ capacity: {sessions: sessions, sessionsMax: maxSessions, sessionsPct: sessPct, messages: messages, messagesMax: maxMessages, messagesPct: msgPct, totalMessageCount: messages, presence: presence, presenceMax: maxPresenceEntries, presencePct: presPct, forwards: fwds, forwardsMax: maxForwardsEntries, forwardsPct: fwdPct, challenges: count core.c, challengesMax: maxChallenges, ackedIds: core.aCount, ackedIdsMax: maxAckedIds, ackOverflow: count core.ao, ackOverflowMax: maxAckOverflow, deliveredIds: core.dCount, deliveredIdsMax: maxDeliveredIds, forwardNonces: core.fnCount, forwardNoncesMax: maxForwardNonces, activePolls: count core.ap, activePollsMax: maxActivePolls, recipientCounts: count core.rc},
5138 federation: {enabled: federationEnabled, inFlight: count core.fi, hungSlots: hungFedSlots, avgLatencyMs: fedAvg, maxLatencyMs: counters.flm, callCount: counters.flc, forwardFailures: count core.ff, oldestForwardAge: oldestFwdAge, oldestAckAge: oldestAckAge, recentGossip: count core.rg, blockedPeers: count core.bp, drainCompletedAt: counters.dca, forwardServers: fwdServerCount, maxForwardQueueDepth: maxFwdDepth},
5239 federation: {enabled: federationEnabled, inFlight: count core.fi, hungSlots: hungFedSlots, avgLatencyMs: fedAvg, maxLatencyMs: counters.flm, callCount: counters.flc, forwardFailures: count core.ff, oldestForwardAge: oldestFwdAge, oldestAckAge: oldestAckAge, recentGossip: count core.rg, blockedPeers: count core.bp, drainCompletedAt: counters.dca, forwardServers: fwdServerCount, maxForwardQueueDepth: maxFwdDepth},
5139 rates: {send: count counters.sr, challenge: count counters.cr, federation: count counters.fr, federationPair: count counters.fpr, global: counters.grc, ip: count counters.ir, lookup: count counters.lr, recv: count counters.rr, pair: count counters.prr, gossipEvent: count core.ger, verifyIp: count core.vir, uniqueKeyIp: count core.ukir, pendingAckCleanups: count core.pac},
5240 rates: {send: count counters.sr, challenge: count counters.cr, federation: count counters.fr, federationPair: count counters.fpr, global: counters.grc, ip: count counters.ir, lookup: count counters.lr, recv: count counters.rr, pair: count counters.prr, gossipEvent: count core.ger, verifyIp: count core.vir, uniqueKeyIp: count core.ukir, pendingAckCleanups: count core.pac},
5140 timing: {pruneCount: counters.pc, pruneRunning: counters.pr, heartbeatCount: counters.hc, uptime: t - counters.sa, lastPruneAt: counters.lp, lastHeartbeatAt: counters.lh, draining: counters.dr > 0}})
5241 timing: {pruneCount: counters.pc, pruneRunning: counters.pr, heartbeatCount: counters.hc, uptime: t - counters.sa, lastPruneAt: counters.lp, lastHeartbeatAt: counters.lh, draining: counters.dr > 0}})
@@ -5189,10 +5290,10 @@ handleStatsKey = \pubkey authorization respond -> do
5189-- GET /stats/top-recipients: Return recipients sorted by pending message count (descending).
5290-- GET /stats/top-recipients: Return recipients sorted by pending message count (descending).
5190-- Uses rcBucket data (per-recipient counts) — O(total_unique_recipients) with no message scan.
5291-- Uses rcBucket data (per-recipient counts) — O(total_unique_recipients) with no message scan.
5191-- Helps operators identify inbox-bombing targets without knowing the recipient key in advance.
5292-- Helps operators identify inbox-bombing targets without knowing the recipient key in advance.
5192−handleTopRecipients = \authorization respond -> do
5293+handleTopRecipients = \limit authorization respond -> do
5193 t <- now
5294 t <- now
5194 globalRateLimited <- checkGlobalRate t
5295 globalRateLimited <- checkGlobalRate t
5195− if globalRateLimited then yield (respond {recipients: [], ok: False {}})
5296+ if globalRateLimited then yield (respond {recipients: [], ok: False {}, total: 0})
5196 else do
5297 else do
5197 let authorized = verifyMaintenanceAuth authorization
5298 let authorized = verifyMaintenanceAuth authorization
5198 if authorized
5299 if authorized
@@ -5200,9 +5301,10 @@ handleTopRecipients = \authorization respond -> do
5200 -- Collect all recipient counts across 16 buckets and sort by count descending
5301 -- Collect all recipient counts across 16 buckets and sort by count descending
5201 allRc <- collectAllRcEntries 0
5302 allRc <- collectAllRcEntries 0
5202 let sorted = sortBy (\c -> 0 - c.count) allRc
5303 let sorted = sortBy (\c -> 0 - c.count) allRc
5203− let top = takeRelation 20 sorted
5304+ let effectiveLimit = if limit <= 0 then 20 else clamp 1 100 limit
5305+ let top = takeRelation effectiveLimit sorted
5204− yield (respond {recipients: top, ok: True {}})
5306+ yield (respond {recipients: top, ok: True {}, total: count allRc})
5205− else yield (respond {recipients: [], ok: False {}})
5307+ else yield (respond {recipients: [], ok: False {}, total: 0})
5206
5308
5207-- Collect RecipientCount entries from all 16 rc buckets
5309-- Collect RecipientCount entries from all 16 rc buckets
5208collectAllRcEntries : Int -> IO [RecipientCount]
5310collectAllRcEntries : Int -> IO [RecipientCount]
@@ -5344,10 +5446,10 @@ handleRestore = \version presenceData ackedIdsData ackOverflowData deliveredIdsD
5344handleAckMessages = \messageIds authorization clientIp respond -> do
5446handleAckMessages = \messageIds authorization clientIp respond -> do
5345 t <- now
5447 t <- now
5346 globalRateLimited <- checkGlobalRate t
5448 globalRateLimited <- checkGlobalRate t
5347− if globalRateLimited then yield (respond {ok: False {}, authorized: False {}, ackedCount: 0})
5449+ if globalRateLimited then yield (respond {ok: False {}, authorized: False {}, ackedCount: 0, error: "rate_limited"})
5348 else
5450 else
5349 withSessionAuth authorization t clientIp
5451 withSessionAuth authorization t clientIp
5350− (yield (respond {ok: False {}, authorized: False {}, ackedCount: 0}))
5452+ (yield (respond {ok: False {}, authorized: False {}, ackedCount: 0, error: "unauthorized"}))
5351 (\pubkey -> do
5453 (\pubkey -> do
5352 -- Uses clientWriteRates (separate from lookupRates) so ack bursts don't starve lookups
5454 -- Uses clientWriteRates (separate from lookupRates) so ack bursts don't starve lookups
5353 ackRateLimited <- atomic do
5455 ackRateLimited <- atomic do
@@ -5355,7 +5457,7 @@ handleAckMessages = \messageIds authorization clientIp respond -> do
5355 let rateResult = checkPubkeyRate cwr pubkey maxClientWriteRatePerKey clientWriteRateWindowMs maxClientWriteRateEntries t
5457 let rateResult = checkPubkeyRate cwr pubkey maxClientWriteRatePerKey clientWriteRateWindowMs maxClientWriteRateEntries t
5356 set *clientWriteRates = rateResult.entries
5458 set *clientWriteRates = rateResult.entries
5357 yield rateResult.limited
5459 yield rateResult.limited
5358− if ackRateLimited then yield (respond {ok: False {}, authorized: True {}, ackedCount: 0})
5460+ if ackRateLimited then yield (respond {ok: False {}, authorized: True {}, ackedCount: 0, error: "rate_limited"})
5359 else do
5461 else do
5360 let dedupedIds = dedupMessageIds messageIds
5462 let dedupedIds = dedupMessageIds messageIds
5361 result <- atomic do
5463 result <- atomic do
@@ -5384,35 +5486,40 @@ handleAckMessages = \messageIds authorization clientIp respond -> do
5384 fork (when federationEnabled (do
5486 fork (when federationEnabled (do
5385 sendAcksAndClean result.pendingAcks
5487 sendAcksAndClean result.pendingAcks
5386 retryEvictedAcks result.evictedEntries result.pendingAcks))
5488 retryEvictedAcks result.evictedEntries result.pendingAcks))
5387− yield (respond {ok: True {}, authorized: True {}, ackedCount: result.ackedCount}))
5489+ yield (respond {ok: True {}, authorized: True {}, ackedCount: result.ackedCount, error: ""}))
5388
5490
5389-- GET /messages/list: List pending messages without acking.
5491-- GET /messages/list: List pending messages without acking.
5390-- Useful after cursor loss (app reinstall, new device) to see what's pending.
5492-- Useful after cursor loss (app reinstall, new device) to see what's pending.
5391-- Rate-limited via lookup rate limiter (read-only info lookup).
5493-- Rate-limited via lookup rate limiter (read-only info lookup).
5392−handleListMessages = \authorization clientIp respond -> do
5494+handleListMessages = \offset authorization clientIp respond -> do
5393 t <- now
5495 t <- now
5394 dp <- *drainPhase
5496 dp <- *drainPhase
5395− if dp >= 2 then yield (respond {messages: [], authorized: False {}, count: 0})
5497+ if dp >= 2 then yield (respond {messages: [], authorized: False {}, count: 0, error: ""})
5396 else do
5498 else do
5397 globalRateLimited <- checkGlobalRate t
5499 globalRateLimited <- checkGlobalRate t
5398− if globalRateLimited then yield (respond {messages: [], authorized: False {}, count: 0})
5500+ if globalRateLimited then yield (respond {messages: [], authorized: False {}, count: 0, error: "rate_limited"})
5399 else
5501 else
5400 withSessionAuth authorization t clientIp
5502 withSessionAuth authorization t clientIp
5401− (yield (respond {messages: [], authorized: False {}, count: 0}))
5503+ (yield (respond {messages: [], authorized: False {}, count: 0, error: ""}))
5402 (\pubkey -> do
5504 (\pubkey -> do
5403 listRateLimited <- atomic do
5505 listRateLimited <- atomic do
5404 lookupRates <- *lookupRates
5506 lookupRates <- *lookupRates
5405 let rateResult = checkPubkeyRate lookupRates pubkey maxLookupRatePerKey lookupRateWindowMs maxLookupRateEntries t
5507 let rateResult = checkPubkeyRate lookupRates pubkey maxLookupRatePerKey lookupRateWindowMs maxLookupRateEntries t
5406 set *lookupRates = rateResult.entries
5508 set *lookupRates = rateResult.entries
5407 yield rateResult.limited
5509 yield rateResult.limited
5408− if listRateLimited then yield (respond {messages: [], authorized: True {}, count: 0})
5510+ if listRateLimited then yield (respond {messages: [], authorized: True {}, count: 0, error: "rate_limited"})
5409 else do
5511 else do
5410 result <- atomic do
5512 result <- atomic do
5411 bucket <- readMessageBucket (pubkeyBucket pubkey)
5513 bucket <- readMessageBucket (pubkeyBucket pubkey)
5412 let myMsgs = sortBy (\m -> m.receivedAt) (do m <- bucket; where m.toKey == pubkey && m.expiresAt > t; yield m)
5514 let myMsgs = sortBy (\m -> m.receivedAt) (do m <- bucket; where m.toKey == pubkey && m.expiresAt > t; yield m)
5515+ let total = count myMsgs
5516+ -- Pagination: skip first `offset` messages, then take up to maxPollEvents.
5517+ -- skipRelation uses fold to drop the first N items from a list.
5518+ let effectiveOffset = clamp 0 total offset
5519+ let afterOffset = skipRelation effectiveOffset myMsgs
5413− let capped = takeRelation maxPollEvents myMsgs
5520+ let capped = takeRelation maxPollEvents afterOffset
5414− yield {msgs: do m <- capped; yield {messageId: m.messageId, timestamp: m.timestamp, receivedAt: m.receivedAt}, total: count myMsgs}
5521+ yield {msgs: do m <- capped; yield {messageId: m.messageId, timestamp: m.timestamp, receivedAt: m.receivedAt}, total: total}
5415− yield (respond {messages: result.msgs, authorized: True {}, count: result.total}))
5522+ yield (respond {messages: result.msgs, authorized: True {}, count: result.total, error: ""}))
5416
5523
5417-- POST /messages/delete: Delete pending messages by ID (recipient-only).
5524-- POST /messages/delete: Delete pending messages by ID (recipient-only).
5418-- The recipient knows their pubkey (toKey) and can be authenticated via session.
5525-- The recipient knows their pubkey (toKey) and can be authenticated via session.
@@ -5422,13 +5529,13 @@ handleListMessages = \authorization clientIp respond -> do
5422handleDeleteMessages = \messageIds authorization clientIp respond -> do
5529handleDeleteMessages = \messageIds authorization clientIp respond -> do
5423 t <- now
5530 t <- now
5424 dp <- *drainPhase
5531 dp <- *drainPhase
5425− if dp >= 2 then yield (respond {ok: False {}, authorized: False {}, deletedCount: 0})
5532+ if dp >= 2 then yield (respond {ok: False {}, authorized: False {}, deletedCount: 0, error: ""})
5426 else do
5533 else do
5427 globalRateLimited <- checkGlobalRate t
5534 globalRateLimited <- checkGlobalRate t
5428− if globalRateLimited then yield (respond {ok: False {}, authorized: False {}, deletedCount: 0})
5535+ if globalRateLimited then yield (respond {ok: False {}, authorized: False {}, deletedCount: 0, error: "rate_limited"})
5429 else
5536 else
5430 withSessionAuth authorization t clientIp
5537 withSessionAuth authorization t clientIp
5431− (yield (respond {ok: False {}, authorized: False {}, deletedCount: 0}))
5538+ (yield (respond {ok: False {}, authorized: False {}, deletedCount: 0, error: "unauthorized"}))
5432 (\pubkey -> do
5539 (\pubkey -> do
5433 -- Uses clientWriteRates (separate from lookupRates) so delete bursts don't starve lookups
5540 -- Uses clientWriteRates (separate from lookupRates) so delete bursts don't starve lookups
5434 deleteRateLimited <- atomic do
5541 deleteRateLimited <- atomic do
@@ -5436,7 +5543,7 @@ handleDeleteMessages = \messageIds authorization clientIp respond -> do
5436 let rateResult = checkPubkeyRate cwr pubkey maxClientWriteRatePerKey clientWriteRateWindowMs maxClientWriteRateEntries t
5543 let rateResult = checkPubkeyRate cwr pubkey maxClientWriteRatePerKey clientWriteRateWindowMs maxClientWriteRateEntries t
5437 set *clientWriteRates = rateResult.entries
5544 set *clientWriteRates = rateResult.entries
5438 yield rateResult.limited
5545 yield rateResult.limited
5439− if deleteRateLimited then yield (respond {ok: False {}, authorized: True {}, deletedCount: 0})
5546+ if deleteRateLimited then yield (respond {ok: False {}, authorized: True {}, deletedCount: 0, error: "rate_limited"})
5440 else do
5547 else do
5441 let dedupedIds = dedupMessageIds messageIds
5548 let dedupedIds = dedupMessageIds messageIds
5442 deleteResult <- atomic do
5549 deleteResult <- atomic do
@@ -5470,7 +5577,7 @@ handleDeleteMessages = \messageIds authorization clientIp respond -> do
5470 fork (when federationEnabled (do
5577 fork (when federationEnabled (do
5471 sendAcksAndClean deleteResult.pendingAcks
5578 sendAcksAndClean deleteResult.pendingAcks
5472 retryEvictedAcks deleteResult.evictedEntries deleteResult.pendingAcks))
5579 retryEvictedAcks deleteResult.evictedEntries deleteResult.pendingAcks))
5473− yield (respond {ok: True {}, authorized: True {}, deletedCount: deleteResult.deletedCount}))
5580+ yield (respond {ok: True {}, authorized: True {}, deletedCount: deleteResult.deletedCount, error: ""}))
5474
5581
5475-- POST /messages/cancel: Retract a previously sent message before the recipient reads it.
5582-- POST /messages/cancel: Retract a previously sent message before the recipient reads it.
5476-- Only works for locally-originated messages where the caller's pubkey matches the
5583-- Only works for locally-originated messages where the caller's pubkey matches the
@@ -5479,13 +5586,13 @@ handleDeleteMessages = \messageIds authorization clientIp respond -> do
5479handleCancelMessage = \messageId to authorization clientIp respond -> do
5586handleCancelMessage = \messageId to authorization clientIp respond -> do
5480 t <- now
5587 t <- now
5481 dp <- *drainPhase
5588 dp <- *drainPhase
5482− if dp >= 2 then yield (respond {ok: False {}, authorized: False {}, cancelled: False {}})
5589+ if dp >= 2 then yield (respond {ok: False {}, authorized: False {}, cancelled: False {}, error: ""})
5483 else do
5590 else do
5484 globalRateLimited <- checkGlobalRate t
5591 globalRateLimited <- checkGlobalRate t
5485− if globalRateLimited then yield (respond {ok: False {}, authorized: False {}, cancelled: False {}})
5592+ if globalRateLimited then yield (respond {ok: False {}, authorized: False {}, cancelled: False {}, error: "rate_limited"})
5486 else
5593 else
5487 withSessionAuth authorization t clientIp
5594 withSessionAuth authorization t clientIp
5488− (yield (respond {ok: False {}, authorized: False {}, cancelled: False {}}))
5595+ (yield (respond {ok: False {}, authorized: False {}, cancelled: False {}, error: "unauthorized"}))
5489 (\senderKey -> do
5596 (\senderKey -> do
5490 -- Uses clientWriteRates (like ack/delete) so cancel bursts don't starve lookups
5597 -- Uses clientWriteRates (like ack/delete) so cancel bursts don't starve lookups
5491 cancelRateLimited <- atomic do
5598 cancelRateLimited <- atomic do
@@ -5493,7 +5600,7 @@ handleCancelMessage = \messageId to authorization clientIp respond -> do
5493 let rateResult = checkPubkeyRate cwr senderKey maxClientWriteRatePerKey clientWriteRateWindowMs maxClientWriteRateEntries t
5600 let rateResult = checkPubkeyRate cwr senderKey maxClientWriteRatePerKey clientWriteRateWindowMs maxClientWriteRateEntries t
5494 set *clientWriteRates = rateResult.entries
5601 set *clientWriteRates = rateResult.entries
5495 yield rateResult.limited
5602 yield rateResult.limited
5496− if cancelRateLimited then yield (respond {ok: False {}, authorized: True {}, cancelled: False {}})
5603+ if cancelRateLimited then yield (respond {ok: False {}, authorized: True {}, cancelled: False {}, error: "rate_limited"})
5497 else do
5604 else do
5498 cancelResult <- atomic do
5605 cancelResult <- atomic do
5499 let b = pubkeyBucket to
5606 let b = pubkeyBucket to
@@ -5522,7 +5629,7 @@ handleCancelMessage = \messageId to authorization clientIp respond -> do
5522 fork (when (federationEnabled && count cancelResult.pendingAcks > 0) (do
5629 fork (when (federationEnabled && count cancelResult.pendingAcks > 0) (do
5523 sendAcksAndClean cancelResult.pendingAcks
5630 sendAcksAndClean cancelResult.pendingAcks
5524 retryEvictedAcks cancelResult.evictedEntries cancelResult.pendingAcks))
5631 retryEvictedAcks cancelResult.evictedEntries cancelResult.pendingAcks))
5525− yield (respond {ok: True {}, authorized: True {}, cancelled: cancelResult.cancelled}))
5632+ yield (respond {ok: True {}, authorized: True {}, cancelled: cancelResult.cancelled, error: ""}))
5526
5633
5527-- POST /messages/batch: Send multiple messages in one request.
5634-- POST /messages/batch: Send multiple messages in one request.
5528-- Auth checked once; each message processed independently with its own rate limiting.
5635-- Auth checked once; each message processed independently with its own rate limiting.
@@ -5849,7 +5956,7 @@ requestPath = \req -> case req of
5849 MessageStatus {} -> "POST /messages/status"
5956 MessageStatus {} -> "POST /messages/status"
5850 AckMessages {} -> "POST /messages/ack"
5957 AckMessages {} -> "POST /messages/ack"
5851 SendBatch {} -> "POST /messages/batch"
5958 SendBatch {} -> "POST /messages/batch"
5852− ListMessages {} -> "GET /messages/list"
5959+ ListMessages {} -> "POST /messages/list"
5853 DeleteMessages {} -> "POST /messages/delete"
5960 DeleteMessages {} -> "POST /messages/delete"
5854 CancelMessage {} -> "POST /messages/cancel"
5961 CancelMessage {} -> "POST /messages/cancel"
5855 LookupPresence {} -> "GET /lookup"
5962 LookupPresence {} -> "GET /lookup"
@@ -5881,7 +5988,7 @@ requestPath = \req -> case req of
5881 Stats {} -> "GET /stats"
5988 Stats {} -> "GET /stats"
5882 StatsPeers {} -> "GET /stats/peers"
5989 StatsPeers {} -> "GET /stats/peers"
5883 StatsKey {} -> "GET /stats/key"
5990 StatsKey {} -> "GET /stats/key"
5884− TopRecipients {} -> "GET /stats/top-recipients"
5991+ TopRecipients {} -> "POST /stats/top-recipients"
5885 Ready {} -> "GET /ready"
5992 Ready {} -> "GET /ready"
5886
5993
5887-- Extract client IP for access logging (empty for federation/maintenance routes)
5994-- Extract client IP for access logging (empty for federation/maintenance routes)
@@ -5944,7 +6051,7 @@ serveInner = \req -> case req of
5944 MessageStatus {to, messageId, authorization, clientIp, respond} -> handleMessageStatus to messageId authorization clientIp respond
6051 MessageStatus {to, messageId, authorization, clientIp, respond} -> handleMessageStatus to messageId authorization clientIp respond
5945 AckMessages {messageIds, authorization, clientIp, respond} -> handleAckMessages messageIds authorization clientIp respond
6052 AckMessages {messageIds, authorization, clientIp, respond} -> handleAckMessages messageIds authorization clientIp respond
5946 SendBatch {messages, authorization, clientIp, respond} -> handleSendBatch messages authorization clientIp respond
6053 SendBatch {messages, authorization, clientIp, respond} -> handleSendBatch messages authorization clientIp respond
5947− ListMessages {authorization, clientIp, respond} -> handleListMessages authorization clientIp respond
6054+ ListMessages {offset, authorization, clientIp, respond} -> handleListMessages offset authorization clientIp respond
5948 DeleteMessages {messageIds, authorization, clientIp, respond} -> handleDeleteMessages messageIds authorization clientIp respond
6055 DeleteMessages {messageIds, authorization, clientIp, respond} -> handleDeleteMessages messageIds authorization clientIp respond
5949 CancelMessage {messageId, to, authorization, clientIp, respond} -> handleCancelMessage messageId to authorization clientIp respond
6056 CancelMessage {messageId, to, authorization, clientIp, respond} -> handleCancelMessage messageId to authorization clientIp respond
5950 LookupPresence {pubkey, authorization, clientIp, respond} -> handleLookupPresence pubkey authorization clientIp respond
6057 LookupPresence {pubkey, authorization, clientIp, respond} -> handleLookupPresence pubkey authorization clientIp respond
@@ -5976,7 +6083,7 @@ serveInner = \req -> case req of
5976 Stats {authorization, respond} -> handleStats authorization respond
6083 Stats {authorization, respond} -> handleStats authorization respond
5977 StatsPeers {authorization, respond} -> handleStatsPeers authorization respond
6084 StatsPeers {authorization, respond} -> handleStatsPeers authorization respond
5978 StatsKey {pubkey, authorization, respond} -> handleStatsKey pubkey authorization respond
6085 StatsKey {pubkey, authorization, respond} -> handleStatsKey pubkey authorization respond
5979− TopRecipients {authorization, respond} -> handleTopRecipients authorization respond
6086+ TopRecipients {limit, authorization, respond} -> handleTopRecipients limit authorization respond
5980 Ready {respond} -> handleReady respond
6087 Ready {respond} -> handleReady respond
5981
6088
5982-- Access-logged request handler: wraps serveInner with timing and correlation ID.
6089-- Access-logged request handler: wraps serveInner with timing and correlation ID.
@@ -6297,6 +6404,8 @@ main = do
6297 set *serverGeneration = t
6404 set *serverGeneration = t
6298 when (maintenanceSecret == "")
6405 when (maintenanceSecret == "")
6299 (logWarn "maintenanceSecret is empty — /maintenance/* and /stats endpoints are disabled")
6406 (logWarn "maintenanceSecret is empty — /maintenance/* and /stats endpoints are disabled")
6407+ when (maintenanceSecretPrevious != "")
6408+ (logInfo "maintenanceSecretPrevious is set — both current and previous secrets are accepted (rotation mode)")
6300 when (matchesHostnameOrSubdomain serverHostN "localhost")
6409 when (matchesHostnameOrSubdomain serverHostN "localhost")
6301 (logWarn ("serverHost is " ++ serverHost ++ " — peers cannot reach this server for federation"))
6410 (logWarn ("serverHost is " ++ serverHost ++ " — peers cannot reach this server for federation"))
6302 when (federationEnabled && isBadServerName serverHostN)
6411 when (federationEnabled && isBadServerName serverHostN)