@@ -20,7 +20,24 @@ listenPort = 8080
2020-- transition window, so operators can rotate without restarting. E.g., add a
2121-- maintenanceSecretPrevious field that's accepted for a configurable overlap period.
2222maintenanceSecret = "" -- 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 = ""
2328federationScheme = "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.
2441-- NOTE: Outbound federation HTTP calls have no runtime-level timeout. The fetchFederation
2542-- wrapper tracks in-flight requests per server and skips servers with outstanding requests
2643-- older than federationFetchTimeoutMs (see shouldRetryServerEx). This prevents a single
@@ -796,7 +813,7 @@ route ClientApi where
796813 -- Clamped to [minMessageTtl, unackedMessageMaxAge]. Short TTLs for ephemeral data
797814 -- (typing indicators, read receipts) free mailbox slots faster than the default 7 days.
798815 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}
800817 = SendMessage
801818 -- Check delivery status of a sent message (caller must know recipient to prove ownership)
802819 POST {to: PubkeyHex, messageId: MessageIdHex} /status headers {authorization: Text, clientIp: Text}
@@ -804,34 +821,35 @@ route ClientApi where
804821 = MessageStatus
805822 -- Explicitly acknowledge received messages by ID (alternative to cursor-based ack in poll)
806823 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}
808825 = AckMessages
809826 -- Send multiple messages in one request (auth checked once; each message rate-limited independently)
810827 POST {messages: [BatchMessage]} /batch headers {authorization: Text, clientIp: Text}
811828 -> {results: [BatchResult], authorized: Bool, retryAfterMs: Int}
812829 = SendBatch
813− -- List pending messages without acking (useful after cursor loss or device migration)
814− GET /messages/list headers {authorization: Text, clientIp: Text}
815− -> {messages: [{messageId: MessageIdHex, timestamp: Timestamp, receivedAt: Timestamp}], authorized: Bool, count: Int}
830+ -- List pending messages without acking (useful after cursor loss or device migration).
831+ -- offset: skip the first N messages (for pagination, default 0).
832+ POST {offset: Int} /list headers {authorization: Text, clientIp: Text}
833+ -> {messages: [{messageId: MessageIdHex, timestamp: Timestamp, receivedAt: Timestamp}], authorized: Bool, count: Int, error: Text}
816834 = ListMessages
817835 -- Delete pending messages by ID (recipient-only; messages don't store sender pubkey)
818836 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}
820838 = DeleteMessages
821839 -- Cancel (retract) a sent message before the recipient reads it.
822840 -- Caller provides messageId and recipient pubkey — knowing the messageId proves
823841 -- sender identity (either client-generated or returned only to the sender).
824842 -- Removes from recipient mailbox, cancels pending forwards, sends federation acks.
825843 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}
827845 = CancelMessage
828846 -- Look up which server a pubkey is present on (for federation routing)
829847 GET /lookup/{pubkey: PubkeyHex} headers {authorization: Text, clientIp: Text}
830− -> {server: AnyServerName, authorized: Bool}
848+ -> {server: AnyServerName, authorized: Bool, error: Text}
831849 = LookupPresence
832850 -- Batch lookup: look up presence for multiple pubkeys in one request
833851 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}
835853 = BatchLookupPresence
836854
837855-- Server-to-server federation API (authenticated via domain callback verification)
@@ -846,6 +864,11 @@ route FederationApi where
846864 -- Receive a forwarded message from a peer server on behalf of a sender.
847865 -- messageTtl: remaining TTL in ms (0 = default). Allows short-lived messages to
848866 -- 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.
849872 POST {toKey: PubkeyHex, encryptedBlob: BlobHex, timestamp: Timestamp, originServer: ServerName, messageId: MessageIdHex, fromServer: ServerName, requestTimestamp: Timestamp, correlationId: Text, messageTtl: Int} /forward headers {authorization: Text}
850873 -> {ok: Bool, error: Text}
851874 = ForwardMessage
@@ -959,9 +982,10 @@ route StatsApi where
959982 GET /stats/key/{pubkey: PubkeyHex} headers {authorization: Text}
960983 -> {messages: Int, presence: [KeyPresenceEntry], sessions: Int, forwards: Int, forwardDetails: [{server: Text, retries: Int, createdAt: Timestamp}], activePolls: Int, ok: Bool}
961984 = StatsKey
962− -- Top recipients by pending message count (for abuse investigation — find inbox-bombing targets)
963− GET /stats/top-recipients headers {authorization: Text}
964− -> {recipients: [RecipientCount], ok: Bool}
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.
987+ POST {limit: Int} /stats/top-recipients headers {authorization: Text}
988+ -> {recipients: [RecipientCount], ok: Bool, total: Int}
965989 = TopRecipients
966990
967991-- Readiness probe: returns false during startup warmup (before first prune completes),
@@ -1099,6 +1123,15 @@ containsPercentEncoding = \s ->
10991123 else if take 1 s == "%" && isHexChar (take 1 (drop 1 s)) && isHexChar (take 1 (drop 2 s)) then True {}
11001124 else containsPercentEncoding (drop 1 s)
11011125
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+
11021135isHexStart : Text -> Bool
11031136isHexStart = \s -> take 2 s == "0x" || take 2 s == "0X"
11041137hasHexPrefix : Text -> Bool
@@ -1224,6 +1257,16 @@ isBadServerName = \sn ->
12241257 || containsSuffix ".example" sn
12251258 || containsSuffix ".invalid" sn
12261259 || 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.
12271270
12281271-- Look up a non-expired session matching the given token (constant-time comparison).
12291272-- Caller passes the appropriate session bucket (sharded by token prefix).
@@ -1261,7 +1304,7 @@ verifyAuthWith = \check authorization ->
12611304 Just {value: token} -> check token
12621305
12631306verifyMaintenanceAuth : Text -> Bool
1264−verifyMaintenanceAuth = verifyAuthWith (\token -> verifySecret token maintenanceSecret)
1307+verifyMaintenanceAuth = verifyAuthWith (\token -> verifySecret token maintenanceSecret || (maintenanceSecretPrevious != "" && verifySecret token maintenanceSecretPrevious))
12651308
12661309-- Check if a server is on the blocklist
12671310isBlockedPeer : Text -> [Text] -> Bool
@@ -1884,11 +1927,13 @@ adjustRecipientCount = \counts pubkey delta ->
18841927-- incrementing existing entries or inserting new ones.
18851928-- NOTE: O(n*k) where k = unique recipients due to `any acc` check inside fold.
18861929-- 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).
18871932countByRecipient = \items ->
18881933 fold (\acc x ->
18891934 if any acc (\c -> c.pubkey == x.toKey)
18901935 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
18921937
18931938-- Batch-decrement recipient counts: subtract per-key removal counts in a single pass
18941939-- instead of calling adjustRecipientCount in a loop.
@@ -2298,6 +2343,15 @@ countAllAcked : Int -> IO Int
22982343countAllAcked = \b -> if b > 15 then yield 0 else do
22992344 bucket <- readAckedBucket b; rest <- countAllAcked (b + 1); yield (count bucket + rest)
23002345
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+
23012355-- ============================================
23022356-- DeliveredIds Bucket Helpers
23032357-- ============================================
@@ -2531,7 +2585,7 @@ checkUniqueKeyIpRate = \entries ip pubkey t ->
25312585 if alreadyTracked then {limited: False {}, entries: entries}
25322586 else if count r.keys >= maxUniqueKeysPerIp
25332587 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)}
25352589
25362590checkServerRecipientRate = \rates server recipient maxRate windowMs maxEntries t ->
25372591 checkRateWindow rates (\r -> r.server == server && r.recipient == recipient)
@@ -2642,14 +2696,14 @@ collectPeerServers = \items getServer ->
26422696samePubkeyServer = \a b -> a.pubkey == b.pubkey && a.server == b.server
26432697pubkeyServer = \e -> {pubkey: e.pubkey, server: e.server}
26442698
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|)).
26462701mergeByKey = \sameKey getFreshness initial items ->
26472702 fold (\acc e ->
26482703 if any acc (\a -> sameKey a e && getFreshness a >= getFreshness e)
26492704 then acc
2650− else union
2651− (do a <- acc; where not (sameKey a e); yield a)
2652− [e]) initial items
2705+ else union [e]
2706+ (do a <- acc; where not (sameKey a e); yield a)) initial items
26532707
26542708-- Merge presence entries by (pubkey, server), keeping the one with the latest expiresAt
26552709mergeByPubkeyServer = mergeByKey samePubkeyServer (\x -> x.expiresAt)
@@ -2776,8 +2830,15 @@ pruneExpired = \t -> do
27762830 set *challenges = do x <- challenges; where x.expiresAt > t; yield x
27772831 -- Prune sessions and count in a single pass (avoids redundant 16-bucket traversal).
27782832 -- pruneAndCountAllSessions applies the filter to each bucket and sums the survivors.
2833+ oldSessionCount <- *totalSessionCount
27792834 sessionCount <- pruneAndCountAllSessions (\bucket -> do x <- bucket; where x.expiresAt > t && t - x.createdAt < maxSessionLifetime; yield x)
2780− atomic do set *totalSessionCount = sessionCount
2835+ atomic do
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)
27812842 -- Prune messages across all 16 buckets concurrently. Each bucket prune is atomic
27822843 -- and touches only its own message bucket + rcBucket. The only shared write is
27832844 -- *totalMessageCount (a single Int), so STM contention is minimal.
@@ -2884,6 +2945,15 @@ pruneExpired = \t -> do
28842945 yield presenceResult
28852946
28862947
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+
28872957splitAtN = \items n ->
28882958 fold (\acc e ->
28892959 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 ->
40694139 set *totalSessionCount = tsc + 1
40704140 yield (Just {value: {peerServers: peerServers, revokedCount: 0}})
40714141 else do
4072− -- Slow path: revocation or capacity pressure — need all sessions
4073− -- TODO: Iterate bucket-by-bucket to avoid materializing all 16 buckets.
4074− -- Currently O(total_sessions); could be O(sessions_in_bucket) for revoke.
4075− allSessions <- readAllSessions
4142+ -- Slow path: revocation or capacity pressure.
4143+ -- Bucket-by-bucket approach: revoke sessions for this pubkey across all
4144+ -- 16 buckets without materializing them into one list. Then add the new
4145+ -- session to its bucket and handle global capacity if needed.
40764146 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
4078− let capped = capPerKeyAndGlobal afterRevoke (\s -> s.pubkey == pubkey) maxSessionsPerKey (\s -> s.expiresAt) t maxSessions
4079− let sessionsWithNew = union capped [newSession]
4080− set *totalSessionCount = count sessionsWithNew
4081− writeAllSessions sessionsWithNew
4147+ when revokeOthers
4148+ (transformAllSessionBuckets (\bucket -> do s <- bucket; where s.pubkey != pubkey; yield s))
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
4168+ set *totalSessionCount = finalCount
40824169 yield (Just {value: {peerServers: peerServers, revokedCount: revokedCount}})
40834170 case authResult of
40844171 Nothing {} -> yield (respond {token: "", expiresAt: 0, error: "draining"})
@@ -4304,11 +4391,11 @@ sendBatchForwardsSequential = \msgs server ->
43044391handleSendMessage = \to encryptedBlob timestamp messageId messageTtl authorization clientIp respond -> do
43054392 t <- now
43064393 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"})
43084395 else do
43094396 effectiveMsgId <- if messageId != "" then yield messageId else genToken
43104397 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"})
43124399 else do
43134400 when (timestamp > t + clockSkewTolerance)
43144401 (logWarn ("sendMessage: future timestamp detected (+" ++ show (timestamp - t) ++ "ms ahead), clamping to now"))
@@ -4327,30 +4414,30 @@ handleSendMessage = \to encryptedBlob timestamp messageId messageTtl authorizati
43274414 _ -> yield {}
43284415 forkSendForwards effectiveMsgId to encryptedBlob clampedTs result.msgExpiresAt result.remoteServers
43294416 yield (case result.status of
4330− MsDuplicate {} -> respond {status: MsDuplicate {}, messageId: effectiveMsgId, retryAfterMs: 0}
4331− MsDelivered {} -> respond {status: MsDelivered {}, messageId: effectiveMsgId, retryAfterMs: 0}
4332− MsFederated {} -> respond {status: MsFederated {}, messageId: effectiveMsgId, retryAfterMs: 0}
4333− MsQueued {} -> respond {status: MsQueued {}, messageId: effectiveMsgId, retryAfterMs: 0}
4334− _ -> respond {status: result.status, messageId: "", retryAfterMs: result.retryAfterMs})
4417+ MsDuplicate {} -> respond {status: MsDuplicate {}, messageId: effectiveMsgId, retryAfterMs: 0, error: ""}
4418+ MsDelivered {} -> respond {status: MsDelivered {}, messageId: effectiveMsgId, retryAfterMs: 0, error: ""}
4419+ MsFederated {} -> respond {status: MsFederated {}, messageId: effectiveMsgId, retryAfterMs: 0, error: ""}
4420+ MsQueued {} -> respond {status: MsQueued {}, messageId: effectiveMsgId, retryAfterMs: 0, error: ""}
4421+ _ -> respond {status: result.status, messageId: "", retryAfterMs: result.retryAfterMs, error: "rejected"})
43354422
43364423-- GET /lookup/:pubkey: Look up the best known server for a pubkey.
43374424-- Returns local server if key is online locally, otherwise the remote with latest expiry.
43384425handleLookupPresence = \pubkey authorization clientIp respond -> do
43394426 t <- now
43404427 globalRateLimited <- checkGlobalRate t
4341− if globalRateLimited then yield (respond {server: "", authorized: False {}})
4428+ if globalRateLimited then yield (respond {server: "", authorized: False {}, error: "rate_limited"})
43424429 else do
43434430 result <- atomic do
43444431 dp <- *drainPhase
4345− if dp >= 2 then yield {server: "", authorized: False {}}
4432+ if dp >= 2 then yield {server: "", authorized: False {}, error: ""}
43464433 else withSessionAuth authorization t clientIp
4347− (yield {server: "", authorized: False {}})
4434+ (yield {server: "", authorized: False {}, error: ""})
43484435 (\senderKey -> do
43494436 lookupRates <- *lookupRates
43504437 let rateResult = checkPubkeyRate lookupRates senderKey maxLookupRatePerKey lookupRateWindowMs maxLookupRateEntries t
43514438 set *lookupRates = rateResult.entries
43524439 if rateResult.limited
4353− then yield {server: "", authorized: True {}}
4440+ then yield {server: "", authorized: True {}, error: "rate_limited"}
43544441 else do
43554442 -- Hot path: read only the target key's presence bucket
43564443 lookupBkt <- readPresenceBucket (pubkeyBucket pubkey)
@@ -4358,8 +4445,8 @@ handleLookupPresence = \pubkey authorization clientIp respond -> do
43584445 -- Mask remote server names: clients only need to know local/remote/unknown,
43594446 -- not which specific peer server a key is homed on (prevents metadata leakage)
43604447 let maskedServer = if foundServer == "" then "" else if isLocalServer foundServer then foundServer else "remote"
4361− yield {server: maskedServer, authorized: True {}})
4362− yield (respond {server: result.server, authorized: result.authorized})
4448+ yield {server: maskedServer, authorized: True {}, error: ""})
4449+ yield (respond {server: result.server, authorized: result.authorized, error: result.error})
43634450
43644451-- POST /lookup/batch: Look up presence for multiple pubkeys in one request.
43654452-- Reads only the needed presence buckets (not all 16), reducing round trips
@@ -4367,20 +4454,20 @@ handleLookupPresence = \pubkey authorization clientIp respond -> do
43674454handleBatchLookupPresence = \pubkeys authorization clientIp respond -> do
43684455 t <- now
43694456 globalRateLimited <- checkGlobalRate t
4370− if globalRateLimited then yield (respond {results: [], authorized: False {}})
4457+ if globalRateLimited then yield (respond {results: [], authorized: False {}, error: "rate_limited"})
43714458 else do
43724459 dp <- *drainPhase
4373− if dp >= 2 then yield (respond {results: [], authorized: False {}})
4460+ if dp >= 2 then yield (respond {results: [], authorized: False {}, error: ""})
43744461 else
43754462 withSessionAuth authorization t clientIp
4376− (yield (respond {results: [], authorized: False {}}))
4463+ (yield (respond {results: [], authorized: False {}, error: ""}))
43774464 (\senderKey -> do
43784465 lookupRateLimited <- atomic do
43794466 lookupRates <- *lookupRates
43804467 let rateResult = checkPubkeyRate lookupRates senderKey maxLookupRatePerKey lookupRateWindowMs maxLookupRateEntries t
43814468 set *lookupRates = rateResult.entries
43824469 yield rateResult.limited
4383− if lookupRateLimited then yield (respond {results: [], authorized: True {}})
4470+ if lookupRateLimited then yield (respond {results: [], authorized: True {}, error: "rate_limited"})
43844471 else do
43854472 let cappedKeys = takeRelation maxBatchLookupKeys pubkeys
43864473 result <- atomic do
@@ -4390,7 +4477,7 @@ handleBatchLookupPresence = \pubkeys authorization clientIp respond -> do
43904477 let foundServer = bestServerForKey (presenceForKey relevantPresence key t)
43914478 in let maskedServer = if foundServer == "" then "" else if isLocalServer foundServer then foundServer else "remote"
43924479 in {pubkey: key, server: maskedServer}))
4393− yield (respond {results: result, authorized: True {}}))
4480+ yield (respond {results: result, authorized: True {}, error: ""}))
43944481
43954482-- POST /messages/status: Check delivery status of a previously sent message.
43964483-- Caller must provide both messageId and recipient pubkey (proves they know the recipient).
@@ -4600,32 +4687,25 @@ handleRecvGossip = \events hopsRemaining fromServer requestTimestamp correlation
46004687 let filteredOnline = resolveByTimestamp onlineEntries trustedOffline
46014688 let filteredOffline = resolveByTimestamp trustedOffline onlineEntries
46024689 let deduped = mergeByPubkeyServer [] filteredOnline
4603− -- Reject gossip entries that claim keys locally authenticated on this server.
4604− -- A peer announcing presence for a key we have a live session for could be a presence
4605− -- hijack attempt: the attacker's server would receive forwarded message copies.
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
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
4690+ -- Combined local-clash check + gossip merge in a single atomic block.
4691+ -- Previously these were two separate atomic blocks reading the same presence buckets;
4692+ -- merging them halves the bucket reads and avoids a TOCTOU gap between clash check and merge.
4693+ -- All entries for a pubkey hash to the same bucket, so per-bucket merge is complete.
4694+ let allGossipEntries = union deduped trustedOffline
46254695 let affectedBuckets = uniqueBucketsFor allGossipEntries (\e -> e.pubkey)
46264696 gossipResult <- atomic do
46274697 selectedPresence <- readSelectedPresenceBuckets affectedBuckets
46284698 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
46294709 -- Pre-filter: when near capacity, reject gossip entries for pubkeys not already in presence.
46304710 -- This prevents a malicious peer from spiking presence above maxPresenceEntries via large batches.
46314711 let capacityDeduped = if tpc >= maxPresenceEntries
@@ -4641,7 +4721,9 @@ handleRecvGossip = \events hopsRemaining fromServer requestTimestamp correlation
46414721 let newPeers = gm.gossipPeerServers
46424722 set *knownPeerServers = fold (\acc s -> if any acc (\a -> a == s) then acc else union acc [s]) knownPeers newPeers
46434723 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"))
46454727 let effectiveEvents = union (effectiveGossipEvents filteredOnline gossipResult.gm.appliedKeys (GossipOnline {})) (effectiveGossipEvents filteredOffline gossipResult.gm.appliedKeys (GossipOffline {}))
46464728 when (not gossipResult.gm.withinCapacity || gossipResult.preFiltered > 0)
46474729 (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
46844766 withFederationAuth authorization t respond fromServer "forward" requestTimestamp (not (isLocalServer originServer) && not (isLocalServer fromServer)) (True {}) (do
46854767 when (fromServer != originServer)
46864768 (logInfo ("forward: relay from " ++ fromServer ++ " (origin: " ++ originServer ++ ") for " ++ toKey))
4687− -- Relay origin verification: for relay forwards, verify originServer is a known peer
4688− -- to prevent ack routing (and metadata leakage) to arbitrary servers.
4769+ -- Relay origin verification: for relay forwards, verify:
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.
46894776 originOk <- if fromServer == originServer then yield (True {})
46904777 else do
46914778 knownPeers <- *knownPeerServers
4692− yield (any knownPeers (\p -> p == originServer))
4779+ blocked <- *blockedPeers
4780+ allowed <- *allowedPeers
4781+ yield (any knownPeers (\p -> p == originServer)
4782+ && not (isBlockedPeer originServer blocked)
4783+ && isAllowedByAllowlist originServer allowed)
46934784 if not originOk
46944785 then do
4695− logWarn ("forward: rejected relay with unknown origin " ++ originServer ++ " from " ++ fromServer)
4786+ logWarn ("forward: rejected relay with untrusted origin " ++ originServer ++ " from " ++ fromServer)
46964787 yield (respond {ok: False {}, error: "unknown_origin"})
46974788 else do
46984789 -- Replay protection: reject forwards with timestamps older than messageMaxAge.
@@ -4752,7 +4843,12 @@ handleForwardMessage = \toKey encryptedBlob timestamp originServer messageId fro
47524843 seqCounter <- *seqCounter
47534844 let seqTs = nextSeqTs t seqCounter
47544845 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
47564852 writeMessageBucket bkt (union bucket
47574853 [{messageId: messageId, toKey: toKey,
47584854 encryptedBlob: encryptedBlob, timestamp: clampedTs, originServer: originServer, receivedAt: seqTs, expiresAt: fwdExpiresAt, senderKey: ""}])
@@ -5080,9 +5176,11 @@ handleStats = \authorization respond -> do
50805176 sc <- *totalSessionCount
50815177 pc <- *totalPresenceCount
50825178 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
50845182 ao <- *ackOverflow
5085− d <- readAllDelivered
5183+ dCount <- countAllDelivered 0
50865184 ff <- *forwardFailures
50875185 ap <- *activePolls
50885186 tmc <- *totalMessageCount
@@ -5095,7 +5193,7 @@ handleStats = \authorization respond -> do
50955193 ger <- *gossipEventRates
50965194 vir <- *verifyIpRates
50975195 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}
50995197 counters <- atomic do
51005198 sr <- *sendRates
51015199 cr <- *challengeRates
@@ -5130,11 +5228,14 @@ handleStats = \authorization respond -> do
51305228 let fedAvg = if counters.flc > 0 then counters.flt / counters.flc else 0
51315229 let hungFedSlots = count (do r <- core.fi; where r.count > 0 && r.firstStartedAt > 0 && t - r.firstStartedAt >= federationFetchTimeoutMs; yield r)
51325230 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
51345235 let fwdServerCount = fwdStats.serverCount
51355236 let maxFwdDepth = fwdStats.maxDepth
51365237 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},
51385239 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},
51395240 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},
51405241 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
51895290-- GET /stats/top-recipients: Return recipients sorted by pending message count (descending).
51905291-- Uses rcBucket data (per-recipient counts) — O(total_unique_recipients) with no message scan.
51915292-- Helps operators identify inbox-bombing targets without knowing the recipient key in advance.
5192−handleTopRecipients = \authorization respond -> do
5293+handleTopRecipients = \limit authorization respond -> do
51935294 t <- now
51945295 globalRateLimited <- checkGlobalRate t
5195− if globalRateLimited then yield (respond {recipients: [], ok: False {}})
5296+ if globalRateLimited then yield (respond {recipients: [], ok: False {}, total: 0})
51965297 else do
51975298 let authorized = verifyMaintenanceAuth authorization
51985299 if authorized
@@ -5200,9 +5301,10 @@ handleTopRecipients = \authorization respond -> do
52005301 -- Collect all recipient counts across 16 buckets and sort by count descending
52015302 allRc <- collectAllRcEntries 0
52025303 let sorted = sortBy (\c -> 0 - c.count) allRc
5203− let top = takeRelation 20 sorted
5204− yield (respond {recipients: top, ok: True {}})
5205− else yield (respond {recipients: [], ok: False {}})
5304+ let effectiveLimit = if limit <= 0 then 20 else clamp 1 100 limit
5305+ let top = takeRelation effectiveLimit sorted
5306+ yield (respond {recipients: top, ok: True {}, total: count allRc})
5307+ else yield (respond {recipients: [], ok: False {}, total: 0})
52065308
52075309-- Collect RecipientCount entries from all 16 rc buckets
52085310collectAllRcEntries : Int -> IO [RecipientCount]
@@ -5344,10 +5446,10 @@ handleRestore = \version presenceData ackedIdsData ackOverflowData deliveredIdsD
53445446handleAckMessages = \messageIds authorization clientIp respond -> do
53455447 t <- now
53465448 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"})
53485450 else
53495451 withSessionAuth authorization t clientIp
5350− (yield (respond {ok: False {}, authorized: False {}, ackedCount: 0}))
5452+ (yield (respond {ok: False {}, authorized: False {}, ackedCount: 0, error: "unauthorized"}))
53515453 (\pubkey -> do
53525454 -- Uses clientWriteRates (separate from lookupRates) so ack bursts don't starve lookups
53535455 ackRateLimited <- atomic do
@@ -5355,7 +5457,7 @@ handleAckMessages = \messageIds authorization clientIp respond -> do
53555457 let rateResult = checkPubkeyRate cwr pubkey maxClientWriteRatePerKey clientWriteRateWindowMs maxClientWriteRateEntries t
53565458 set *clientWriteRates = rateResult.entries
53575459 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"})
53595461 else do
53605462 let dedupedIds = dedupMessageIds messageIds
53615463 result <- atomic do
@@ -5384,35 +5486,40 @@ handleAckMessages = \messageIds authorization clientIp respond -> do
53845486 fork (when federationEnabled (do
53855487 sendAcksAndClean result.pendingAcks
53865488 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: ""}))
53885490
53895491-- GET /messages/list: List pending messages without acking.
53905492-- Useful after cursor loss (app reinstall, new device) to see what's pending.
53915493-- Rate-limited via lookup rate limiter (read-only info lookup).
5392−handleListMessages = \authorization clientIp respond -> do
5494+handleListMessages = \offset authorization clientIp respond -> do
53935495 t <- now
53945496 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: ""})
53965498 else do
53975499 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"})
53995501 else
54005502 withSessionAuth authorization t clientIp
5401− (yield (respond {messages: [], authorized: False {}, count: 0}))
5503+ (yield (respond {messages: [], authorized: False {}, count: 0, error: ""}))
54025504 (\pubkey -> do
54035505 listRateLimited <- atomic do
54045506 lookupRates <- *lookupRates
54055507 let rateResult = checkPubkeyRate lookupRates pubkey maxLookupRatePerKey lookupRateWindowMs maxLookupRateEntries t
54065508 set *lookupRates = rateResult.entries
54075509 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"})
54095511 else do
54105512 result <- atomic do
54115513 bucket <- readMessageBucket (pubkeyBucket pubkey)
54125514 let myMsgs = sortBy (\m -> m.receivedAt) (do m <- bucket; where m.toKey == pubkey && m.expiresAt > t; yield m)
5413− let capped = takeRelation maxPollEvents myMsgs
5414− yield {msgs: do m <- capped; yield {messageId: m.messageId, timestamp: m.timestamp, receivedAt: m.receivedAt}, total: count myMsgs}
5415− yield (respond {messages: result.msgs, authorized: True {}, count: result.total}))
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
5520+ let capped = takeRelation maxPollEvents afterOffset
5521+ yield {msgs: do m <- capped; yield {messageId: m.messageId, timestamp: m.timestamp, receivedAt: m.receivedAt}, total: total}
5522+ yield (respond {messages: result.msgs, authorized: True {}, count: result.total, error: ""}))
54165523
54175524-- POST /messages/delete: Delete pending messages by ID (recipient-only).
54185525-- The recipient knows their pubkey (toKey) and can be authenticated via session.
@@ -5422,13 +5529,13 @@ handleListMessages = \authorization clientIp respond -> do
54225529handleDeleteMessages = \messageIds authorization clientIp respond -> do
54235530 t <- now
54245531 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: ""})
54265533 else do
54275534 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"})
54295536 else
54305537 withSessionAuth authorization t clientIp
5431− (yield (respond {ok: False {}, authorized: False {}, deletedCount: 0}))
5538+ (yield (respond {ok: False {}, authorized: False {}, deletedCount: 0, error: "unauthorized"}))
54325539 (\pubkey -> do
54335540 -- Uses clientWriteRates (separate from lookupRates) so delete bursts don't starve lookups
54345541 deleteRateLimited <- atomic do
@@ -5436,7 +5543,7 @@ handleDeleteMessages = \messageIds authorization clientIp respond -> do
54365543 let rateResult = checkPubkeyRate cwr pubkey maxClientWriteRatePerKey clientWriteRateWindowMs maxClientWriteRateEntries t
54375544 set *clientWriteRates = rateResult.entries
54385545 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"})
54405547 else do
54415548 let dedupedIds = dedupMessageIds messageIds
54425549 deleteResult <- atomic do
@@ -5470,7 +5577,7 @@ handleDeleteMessages = \messageIds authorization clientIp respond -> do
54705577 fork (when federationEnabled (do
54715578 sendAcksAndClean deleteResult.pendingAcks
54725579 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: ""}))
54745581
54755582-- POST /messages/cancel: Retract a previously sent message before the recipient reads it.
54765583-- Only works for locally-originated messages where the caller's pubkey matches the
@@ -5479,13 +5586,13 @@ handleDeleteMessages = \messageIds authorization clientIp respond -> do
54795586handleCancelMessage = \messageId to authorization clientIp respond -> do
54805587 t <- now
54815588 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: ""})
54835590 else do
54845591 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"})
54865593 else
54875594 withSessionAuth authorization t clientIp
5488− (yield (respond {ok: False {}, authorized: False {}, cancelled: False {}}))
5595+ (yield (respond {ok: False {}, authorized: False {}, cancelled: False {}, error: "unauthorized"}))
54895596 (\senderKey -> do
54905597 -- Uses clientWriteRates (like ack/delete) so cancel bursts don't starve lookups
54915598 cancelRateLimited <- atomic do
@@ -5493,7 +5600,7 @@ handleCancelMessage = \messageId to authorization clientIp respond -> do
54935600 let rateResult = checkPubkeyRate cwr senderKey maxClientWriteRatePerKey clientWriteRateWindowMs maxClientWriteRateEntries t
54945601 set *clientWriteRates = rateResult.entries
54955602 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"})
54975604 else do
54985605 cancelResult <- atomic do
54995606 let b = pubkeyBucket to
@@ -5522,7 +5629,7 @@ handleCancelMessage = \messageId to authorization clientIp respond -> do
55225629 fork (when (federationEnabled && count cancelResult.pendingAcks > 0) (do
55235630 sendAcksAndClean cancelResult.pendingAcks
55245631 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: ""}))
55265633
55275634-- POST /messages/batch: Send multiple messages in one request.
55285635-- Auth checked once; each message processed independently with its own rate limiting.
@@ -5849,7 +5956,7 @@ requestPath = \req -> case req of
58495956 MessageStatus {} -> "POST /messages/status"
58505957 AckMessages {} -> "POST /messages/ack"
58515958 SendBatch {} -> "POST /messages/batch"
5852− ListMessages {} -> "GET /messages/list"
5959+ ListMessages {} -> "POST /messages/list"
58535960 DeleteMessages {} -> "POST /messages/delete"
58545961 CancelMessage {} -> "POST /messages/cancel"
58555962 LookupPresence {} -> "GET /lookup"
@@ -5881,7 +5988,7 @@ requestPath = \req -> case req of
58815988 Stats {} -> "GET /stats"
58825989 StatsPeers {} -> "GET /stats/peers"
58835990 StatsKey {} -> "GET /stats/key"
5884− TopRecipients {} -> "GET /stats/top-recipients"
5991+ TopRecipients {} -> "POST /stats/top-recipients"
58855992 Ready {} -> "GET /ready"
58865993
58875994-- Extract client IP for access logging (empty for federation/maintenance routes)
@@ -5944,7 +6051,7 @@ serveInner = \req -> case req of
59446051 MessageStatus {to, messageId, authorization, clientIp, respond} -> handleMessageStatus to messageId authorization clientIp respond
59456052 AckMessages {messageIds, authorization, clientIp, respond} -> handleAckMessages messageIds authorization clientIp respond
59466053 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
59486055 DeleteMessages {messageIds, authorization, clientIp, respond} -> handleDeleteMessages messageIds authorization clientIp respond
59496056 CancelMessage {messageId, to, authorization, clientIp, respond} -> handleCancelMessage messageId to authorization clientIp respond
59506057 LookupPresence {pubkey, authorization, clientIp, respond} -> handleLookupPresence pubkey authorization clientIp respond
@@ -5976,7 +6083,7 @@ serveInner = \req -> case req of
59766083 Stats {authorization, respond} -> handleStats authorization respond
59776084 StatsPeers {authorization, respond} -> handleStatsPeers authorization respond
59786085 StatsKey {pubkey, authorization, respond} -> handleStatsKey pubkey authorization respond
5979− TopRecipients {authorization, respond} -> handleTopRecipients authorization respond
6086+ TopRecipients {limit, authorization, respond} -> handleTopRecipients limit authorization respond
59806087 Ready {respond} -> handleReady respond
59816088
59826089-- Access-logged request handler: wraps serveInner with timing and correlation ID.
@@ -6297,6 +6404,8 @@ main = do
62976404 set *serverGeneration = t
62986405 when (maintenanceSecret == "")
62996406 (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)")
63006409 when (matchesHostnameOrSubdomain serverHostN "localhost")
63016410 (logWarn ("serverHost is " ++ serverHost ++ " — peers cannot reach this server for federation"))
63026411 when (federationEnabled && isBadServerName serverHostN)