on master help sign in
Add comprehensive comments to server.knot
e34e431iko
.claude/scheduled_tasks.lockdeleted−1
@@ -1 +0,0 @@
1−{"sessionId":"a5c08384-ac7a-4203-bab1-eeac9e62d194","pid":36756,"procStart":"Sun May 3 08:43:12 2026","acquiredAt":1777875764343}
server.knot+87−74
@@ -59,9 +59,6 @@ maxBlobLen = 41943040
59-- Length of a hex-encoded Ed25519 signature (64 bytes → 128 hex).
59-- Length of a hex-encoded Ed25519 signature (64 bytes → 128 hex).
60signatureLen : Int
60signatureLen : Int
61signatureLen = 128
61signatureLen = 128
62−-- Cap on the number of message IDs accepted in a single ack/lookup request.
63−maxMessageIds : Int
64−maxMessageIds = 100
65-- Cap on items in a batched send request.
62-- Cap on items in a batched send request.
66maxBatchSize : Int
63maxBatchSize : Int
67maxBatchSize = 100
64maxBatchSize = 100
@@ -211,14 +208,6 @@ federationUrl = \server -> federationScheme ++ server
211requireClientIp : Bool
208requireClientIp : Bool
212requireClientIp = True {}
209requireClientIp = True {}
213
210
214−-- Drop the Ms unit and convert milliseconds to seconds.
215−msToS : Int<Ms> -> Int<S>
216−msToS = \ms -> stripUnit ms / 1000
217−
218−-- Drop the S unit and convert seconds to milliseconds.
219−sToMs : Int<S> -> Int<Ms>
220−sToMs = \s -> stripUnit s * 1000
221−
222-- ============================================
211-- ============================================
223-- Types
212-- Types
224-- ============================================
213-- ============================================
@@ -307,9 +296,13 @@ type DeliveredEntry = {messageId: MessageIdHex, toKey: PubkeyHex, deliveredAt: T
307-- Running failure counter for a peer; keyed by `server`. Drives `retryBackoff`.
296-- Running failure counter for a peer; keyed by `server`. Drives `retryBackoff`.
308type ForwardFailure = {server: Text, failedAt: Timestamp, failures: Int, firstFailedAt: Timestamp}
297type ForwardFailure = {server: Text, failedAt: Timestamp, failures: Int, firstFailedAt: Timestamp}
309-- One fixed-window rate counter. `key` identifies the subject (pubkey, ip,
298-- One fixed-window rate counter. `key` identifies the subject (pubkey, ip,
310−-- server, or composite "sender|recipient" string).
299+-- server, or composite "sender|recipient" string). `windowStart` is the wall
300+-- time at which the current window began; once `t - windowStart` exceeds the
301+-- configured window length the entry is recycled (counter resets to 1).
311type RateEntry = {key: Text, windowStart: Timestamp, count: Int}
302type RateEntry = {key: Text, windowStart: Timestamp, count: Int}
312-- A live long-poll slot held by a pubkey; used to cap concurrency per key.
303-- A live long-poll slot held by a pubkey; used to cap concurrency per key.
304+-- `startedAt` lets `pruneExpired` reap slots whose holder vanished without
305+-- cleanly exiting `pollOnce` (network drop mid-poll).
313type ActivePoll = {pubkey: PubkeyHex, startedAt: Timestamp}
306type ActivePoll = {pubkey: PubkeyHex, startedAt: Timestamp}
314
307
315-- ============================================
308-- ============================================
@@ -319,46 +312,81 @@ type ActivePoll = {pubkey: PubkeyHex, startedAt: Timestamp}
319-- scan is O(n); scaling is the first thing the production version replaces.
312-- scan is O(n); scaling is the first thing the production version replaces.
320-- Grouped here: auth (challenges/sessions), mailbox (messages/forwards/acks),
313-- Grouped here: auth (challenges/sessions), mailbox (messages/forwards/acks),
321-- presence, rate-limit windows, and bookkeeping counters.
314-- presence, rate-limit windows, and bookkeeping counters.
315+-- The Knot runtime persists every `*ref` declared here through restarts (see
316+-- the project memory note about SQLite-backed refs); nothing in this file is
317+-- truly volatile despite being typed as plain in-memory lists.
322
318
319+-- Outstanding /auth/challenge nonces awaiting /auth/verify. Capped overall and per-pubkey.
323*challenges : [Challenge]
320*challenges : [Challenge]
321+-- Live bearer tokens. Source of truth for `withSessionAuth`; trimmed by `pruneExpired`.
324*sessions : [Session]
322*sessions : [Session]
323+-- Mailbox: ciphertexts addressed to local recipients, awaiting poll or federation forward.
325*messages : [Message]
324*messages : [Message]
325+-- Last-known location of each pubkey (local sessions and gossiped remote presence).
326*presence : [PresenceEntry]
326*presence : [PresenceEntry]
327+-- Cross-server outbox: messages queued for delivery to a peer, with retry counts.
327*forwards : [ForwardEntry]
328*forwards : [ForwardEntry]
329+-- Tombstones of already-delivered message ids; combined with `*messages` for dedup.
328*deliveredIds : [DeliveredEntry]
330*deliveredIds : [DeliveredEntry]
331+-- Per-peer failure counters; drives `retryBackoff` and the federation health view.
329*forwardFailures : [ForwardFailure]
332*forwardFailures : [ForwardFailure]
333+-- Currently-blocking long-poll slots, used to cap concurrent /poll holds per pubkey.
330*activePolls : [ActivePoll]
334*activePolls : [ActivePoll]
331
335
336+-- Per-key fixed-window counters for /messages send-side rate limiting.
332*sendRates : [RateEntry]
337*sendRates : [RateEntry]
338+-- Per-recipient counters; protects a popular key from being flooded by many senders.
333*recvRates : [RateEntry]
339*recvRates : [RateEntry]
340+-- Per-(sender, recipient) pair counters; second-axis brake on chat spam.
334*pairRates : [RateEntry]
341*pairRates : [RateEntry]
342+-- Per-key /auth/challenge counters (separate from `*challenges` capacity gate).
335*challengeRates : [RateEntry]
343*challengeRates : [RateEntry]
344+-- Per-key counters for read-only lookups (presence, /messages/list).
336*lookupRates : [RateEntry]
345*lookupRates : [RateEntry]
346+-- Per-key counters for misc client writes (/auth/revoke, /auth/logout, /messages/status).
337*clientWriteRates : [RateEntry]
347*clientWriteRates : [RateEntry]
348+-- Per-peer counters for inbound federation calls.
338*federationRates : [RateEntry]
349*federationRates : [RateEntry]
350+-- Per-source-IP counters; gate for unauthenticated traffic.
339*ipRates : [RateEntry]
351*ipRates : [RateEntry]
340
352
353+-- Global request-rate gate (single counter shared across all clients).
341*globalRateCount : Int
354*globalRateCount : Int
342*globalRateWindowStart : Timestamp
355*globalRateWindowStart : Timestamp
356+-- Global gate charged only by /auth/* endpoints, on top of `*globalRateCount`.
343*authGlobalRateCount : Int
357*authGlobalRateCount : Int
344*authGlobalRateWindowStart : Timestamp
358*authGlobalRateWindowStart : Timestamp
345
359
360+-- Strictly-monotonic sequence used as `Message.receivedAt`; guarantees poll-cursor ordering.
346*seqCounter : Timestamp
361*seqCounter : Timestamp
362+-- Bumped by send/heartbeat to wake all blocked /poll holders.
347*pollWakeup : Timestamp
363*pollWakeup : Timestamp
364+-- Wall time of the last completed prune sweep (for /health and /ready).
348*lastPrunedAt : Timestamp
365*lastPrunedAt : Timestamp
366+-- Wall time of the last poll-heartbeat tick (for /health watchdog).
349*lastHeartbeatAt : Timestamp
367*lastHeartbeatAt : Timestamp
368+-- Wall time of the last self-presence refresh broadcast to peers.
350*lastPresenceRefresh : Timestamp
369*lastPresenceRefresh : Timestamp
370+-- Wall time of process start; underpins /stats uptime and /health warmup window.
351*startedAt : Timestamp
371*startedAt : Timestamp
372+-- Random 64-bit-ish identity stamped at startup. Clients echo it back on /poll;
373+-- any mismatch means the server restarted and the client must reset its cursor.
352*serverGeneration : Int
374*serverGeneration : Int
353
375
376+-- Bearer secret accepted on /maintenance/* endpoints. Rotated via /maintenance/rotate-secret;
377+-- empty value disables maintenance entirely (every check returns false).
354*runtimeMaintenanceSecret : Text
378*runtimeMaintenanceSecret : Text
355
379
380+-- Ed25519 federation keypair (hex). Generated on first startup if blank, then persisted.
356*federationPrivateKeyHex : Text
381*federationPrivateKeyHex : Text
357*federationPublicKeyHex : Text
382*federationPublicKeyHex : Text
358
383
384+-- Operator-managed peer deny-list; checked on every inbound and outbound federation call.
359*blockedPeers : [Text]
385*blockedPeers : [Text]
386+-- Operator-managed allow-list. When non-empty, only listed peers may federate (whitelist mode).
360*allowedPeers : [Text]
387*allowedPeers : [Text]
361
388
389+-- Cumulative tick counters surfaced on /stats; bumped by the background loops.
362*pruneCount : Int
390*pruneCount : Int
363*heartbeatCount : Int
391*heartbeatCount : Int
364
392
@@ -385,9 +413,7 @@ route ClientApi where
385 POST {cursor: Timestamp, generation: Int} /poll headers {authorization: Text, clientIp: Text} -> {events: [PollEvent], cursor: Timestamp, authorized: Bool, rateLimited: Bool, generation: Int, heartbeat: Bool, retryAfterMs: Int, cursorReset: Bool, hasMore: Bool, cursorStale: Bool, loadPct: Int} = Poll
413 POST {cursor: Timestamp, generation: Int} /poll headers {authorization: Text, clientIp: Text} -> {events: [PollEvent], cursor: Timestamp, authorized: Bool, rateLimited: Bool, generation: Int, heartbeat: Bool, retryAfterMs: Int, cursorReset: Bool, hasMore: Bool, cursorStale: Bool, loadPct: Int} = Poll
386 POST {messages: [{to: PubkeyHex, encryptedBlob: BlobHex}]} /messages headers {authorization: Text, clientIp: Text} -> {results: [{messageId: MessageIdHex, error: Text}], error: Text, retryAfterMs: Int} = SendMessages
414 POST {messages: [{to: PubkeyHex, encryptedBlob: BlobHex}]} /messages headers {authorization: Text, clientIp: Text} -> {results: [{messageId: MessageIdHex, error: Text}], error: Text, retryAfterMs: Int} = SendMessages
387 POST {to: PubkeyHex, messageId: MessageIdHex} /messages/status headers {authorization: Text, clientIp: Text} -> {status: Text, authorized: Bool} = MessageStatus
415 POST {to: PubkeyHex, messageId: MessageIdHex} /messages/status headers {authorization: Text, clientIp: Text} -> {status: Text, authorized: Bool} = MessageStatus
388− POST {messageIds: [MessageIdHex]} /messages/ack headers {authorization: Text, clientIp: Text} -> {ok: Bool, authorized: Bool, ackedCount: Int, error: Text} = AckMessages
389 POST {offset: Int} /messages/list headers {authorization: Text, clientIp: Text} -> {messages: [{messageId: MessageIdHex, receivedAt: Timestamp}], authorized: Bool, count: Int, error: Text} = ListMessages
416 POST {offset: Int} /messages/list headers {authorization: Text, clientIp: Text} -> {messages: [{messageId: MessageIdHex, receivedAt: Timestamp}], authorized: Bool, count: Int, error: Text} = ListMessages
390− POST {messageIds: [MessageIdHex]} /messages/delete headers {authorization: Text, clientIp: Text} -> {ok: Bool, authorized: Bool, deletedCount: Int, error: Text} = DeleteMessages
391 GET /lookup/{pubkey: PubkeyHex} headers {authorization: Text, clientIp: Text} -> {server: ServerName, authorized: Bool, error: Text} = LookupPresence
417 GET /lookup/{pubkey: PubkeyHex} headers {authorization: Text, clientIp: Text} -> {server: ServerName, authorized: Bool, error: Text} = LookupPresence
392 POST {pubkeys: [PubkeyHex]} /lookup/batch headers {authorization: Text, clientIp: Text} -> {results: [{pubkey: PubkeyHex, server: ServerName}], authorized: Bool, error: Text} = BatchLookupPresence
418 POST {pubkeys: [PubkeyHex]} /lookup/batch headers {authorization: Text, clientIp: Text} -> {results: [{pubkey: PubkeyHex, server: ServerName}], authorized: Bool, error: Text} = BatchLookupPresence
393
419
@@ -467,35 +493,44 @@ route Api = AuthApi | ClientApi | FederationApi | FederationPubkeyApi | Maintena
467-- Everything is intentionally O(n) over flat lists; performance is sacrificed
493-- Everything is intentionally O(n) over flat lists; performance is sacrificed
468-- for clarity per the file header.
494-- for clarity per the file header.
469
495
496+-- Keep elements satisfying `pred`. Uses Knot's relation comprehension (`where`).
470filter : a b -> (b -> Bool) -> a b
497filter : a b -> (b -> Bool) -> a b
471filter = \xs pred -> do
498filter = \xs pred -> do
472 x <- xs
499 x <- xs
473 where pred x
500 where pred x
474 yield x
501 yield x
502+-- Inverse of `filter`: drop elements satisfying `pred`.
475removeWhere : a b -> (b -> Bool) -> a b
503removeWhere : a b -> (b -> Bool) -> a b
476removeWhere = \xs pred -> filter xs (\x -> not pred x)
504removeWhere = \xs pred -> filter xs (\x -> not pred x)
505+-- Map without filtering (a.k.a. classic `map`).
477pluck : a b -> (b -> c) -> a c
506pluck : a b -> (b -> c) -> a c
478pluck = \xs f -> do
507pluck = \xs f -> do
479 x <- xs
508 x <- xs
480 yield (f x)
509 yield (f x)
510+-- Filter then map in one pass.
481mapWhere : a b -> (b -> Bool) -> (b -> c) -> a c
511mapWhere : a b -> (b -> Bool) -> (b -> c) -> a c
482mapWhere = \xs pred -> pluck (filter xs pred)
512mapWhere = \xs pred -> pluck (filter xs pred)
483countWhere : a b -> (b -> Bool) -> Int
513countWhere : a b -> (b -> Bool) -> Int
484countWhere = \xs pred -> count (filter xs pred)
514countWhere = \xs pred -> count (filter xs pred)
515+-- True when at least one element matches.
485any : [a] -> (a -> Bool) -> Bool
516any : [a] -> (a -> Bool) -> Bool
486any = \xs pred -> countWhere xs pred > 0
517any = \xs pred -> countWhere xs pred > 0
518+-- True when no element matches.
487none : [a] -> (a -> Bool) -> Bool
519none : [a] -> (a -> Bool) -> Bool
488none = \xs pred -> not any xs pred
520none = \xs pred -> not any xs pred
521+-- True when every element matches.
489all : [a] -> (a -> Bool) -> Bool
522all : [a] -> (a -> Bool) -> Bool
490all = \xs pred -> none xs (\x -> not pred x)
523all = \xs pred -> none xs (\x -> not pred x)
491findFirst : [a] -> (a -> Bool) -> Maybe a
524findFirst : [a] -> (a -> Bool) -> Maybe a
492findFirst = \xs pred -> headOf (filter xs pred)
525findFirst = \xs pred -> headOf (filter xs pred)
526+-- Run `action` only when `cond` is True; otherwise no-op.
493when : Bool -> IO {} {} -> IO {} {}
527when : Bool -> IO {} {} -> IO {} {}
494when = \cond action -> if cond then action else yield {}
528when = \cond action -> if cond then action else yield {}
495min : a -> a -> a
529min : a -> a -> a
496min = \a b -> if a < b then a else b
530min = \a b -> if a < b then a else b
497max : a -> a -> a
531max : a -> a -> a
498max = \a b -> if a > b then a else b
532max = \a b -> if a > b then a else b
533+-- Confine `x` to the closed interval [lo, hi].
499clamp : a -> a -> a -> a
534clamp : a -> a -> a -> a
500clamp = \lo hi x -> max lo (min hi x)
535clamp = \lo hi x -> max lo (min hi x)
501abs : Int -> Int
536abs : Int -> Int
@@ -509,6 +544,8 @@ headOf : [a] -> Maybe a
509headOf = \items -> fold (\_ x -> Just {value: x}) (Nothing {}) (takeRelation 1 items)
544headOf = \items -> fold (\_ x -> Just {value: x}) (Nothing {}) (takeRelation 1 items)
510
545
511-- Cap to maxN by dropping the smallest-key entries (LRU-style). Always sorts.
546-- Cap to maxN by dropping the smallest-key entries (LRU-style). Always sorts.
547+-- For session/challenge/rate caps, `getKey` is typically `expiresAt` or
548+-- `windowStart` so the oldest entries are evicted first.
512capList : [a] -> Int -> (a -> b) -> [a]
549capList : [a] -> Int -> (a -> b) -> [a]
513capList = \items maxN getKey -> dropRelation (max 0 (count items - maxN)) (sortBy getKey items)
550capList = \items maxN getKey -> dropRelation (max 0 (count items - maxN)) (sortBy getKey items)
514
551
@@ -516,16 +553,10 @@ capList = \items maxN getKey -> dropRelation (max 0 (count items - maxN)) (sortB
516pct : Int -> Int -> Int
553pct : Int -> Int -> Int
517pct = \cur maxN -> if maxN > 0 then cur * 100 / maxN else 0
554pct = \cur maxN -> if maxN > 0 then cur * 100 / maxN else 0
518
555
519−-- Common ack/delete tail: pull matching messages, drop them and their forwards.
520−removeForKey : (forall r. {toKey: PubkeyHex, encryptedBlob: BlobHex | r} -> Bool) -> IO {rw *forwards, rw *messages} [Message]
521−removeForKey = \pred -> do
522− msgs <- *messages
523− fwds <- *forwards
524− *messages = removeWhere msgs pred
525− *forwards = removeWhere fwds pred
526− yield (filter msgs pred)
527−
528−-- Cap pred-matching entries to maxN-1 (caller adds one more).
556+-- Cap pred-matching entries to maxN-1 (caller adds one more). Splits the list
557+-- into "doesn't match" (untouched) and "matches" (capped + LRU-evicted) so a
558+-- single key can't exhaust a global pool — see `maxChallengesPerKey` and
559+-- `maxSessionsPerKey`.
529capPerKey : [a] -> (a -> Bool) -> Int -> (a -> b) -> [a]
560capPerKey : [a] -> (a -> Bool) -> Int -> (a -> b) -> [a]
530capPerKey = \items pred maxN getKey -> union (removeWhere items pred) (capList (filter items pred) (maxN - 1) getKey)
561capPerKey = \items pred maxN getKey -> union (removeWhere items pred) (capList (filter items pred) (maxN - 1) getKey)
531
562
@@ -545,7 +576,8 @@ emptyPollResp = \cursor authorized rateLimited gen retryAfterMs cursorWasReset -
545 loadPct: 0
576 loadPct: 0
546}
577}
547
578
548−-- Sequence an IO action over each item, in order.
579+-- Sequence an IO action over each item, in order. Recurses through the list
580+-- one element at a time; this is the closest equivalent of `mapM_` here.
549forEach : [a] -> (a -> IO {} {}) -> IO {} {}
581forEach : [a] -> (a -> IO {} {}) -> IO {} {}
550forEach = \items action -> case single (takeRelation 1 items) of
582forEach = \items action -> case single (takeRelation 1 items) of
551 Nothing {} -> yield {}
583 Nothing {} -> yield {}
@@ -562,7 +594,8 @@ mapEach = \items action -> case headOf items of
562 rest <- mapEach (dropRelation 1 items) action
594 rest <- mapEach (dropRelation 1 items) action
563 yield (union [r] rest)
595 yield (union [r] rest)
564
596
565−-- Hex tokens: one random hex char per recursive call.
597+-- Hex tokens: one random hex char per recursive call. `n` is the desired
598+-- length in characters (so n=48 yields 24 random bytes of entropy).
566genTokenN : Int -> IO {random} Text
599genTokenN : Int -> IO {random} Text
567genTokenN = \n -> if n <= 0
600genTokenN = \n -> if n <= 0
568then yield ""
601then yield ""
@@ -571,7 +604,8 @@ else do
571 rest <- genTokenN (n - 1)
604 rest <- genTokenN (n - 1)
572 yield (take 1 (drop d "0123456789abcdef") ++ rest)
605 yield (take 1 (drop d "0123456789abcdef") ++ rest)
573
606
574−-- Standard 48-char hex token used for session bearers and federation nonces.
607+-- Standard 48-char hex token used for session bearers, /auth challenges, and
608+-- federation auth nonces (192 bits of entropy, well beyond birthday bound).
575genToken : IO {random} Text
609genToken : IO {random} Text
576genToken = genTokenN 48
610genToken = genTokenN 48
577
611
@@ -619,7 +653,12 @@ containsSuffix : Text -> Text -> Bool
619containsSuffix = \suffix s -> drop (max 0 (count (chars s) - count (chars suffix))) s == suffix
653containsSuffix = \suffix s -> drop (max 0 (count (chars s) - count (chars suffix))) s == suffix
620
654
621-- Block private/reserved/internal hostnames (SSRF defense). Skips obscure encoding
655-- Block private/reserved/internal hostnames (SSRF defense). Skips obscure encoding
622−-- tricks (octal, hex, percent-encoding) but covers the high-value cases.
656+-- tricks (octal, hex, percent-encoding) but covers the high-value cases:
657+-- `localhost`, RFC1918 ranges (10/8, 192.168/16, 169.254/16), the IPv4 zero
658+-- net, IPv6 loopback (`::`/`::1`), bracketed IPv6 literals, and the `.local`
659+-- / `.internal` / `.arpa` / `.onion` suffixes. Called at federation egress
660+-- (so we never `fetch` a peer pubkey from loopback) and on inbound requests
661+-- (so a peer can't claim a bogus `fromServer`).
623isBadServerName : Text -> Bool
662isBadServerName : Text -> Bool
624isBadServerName = \sn -> sn == "" || sn == "localhost" || not all (chars sn) (\c -> contains c "0123456789abcdefghijklmnopqrstuvwxyz-.:") || any [
663isBadServerName = \sn -> sn == "" || sn == "localhost" || not all (chars sn) (\c -> contains c "0123456789abcdefghijklmnopqrstuvwxyz-.:") || any [
625 ".",
664 ".",
@@ -662,7 +701,7 @@ verifyMaintenanceAuth = \authorization -> do
662
701
663-- Branch on maintenance-secret auth: run `onAuth` when the bearer matches,
702-- Branch on maintenance-secret auth: run `onAuth` when the bearer matches,
664-- `onUnauth` otherwise.
703-- `onUnauth` otherwise.
665−withMaintAuth : Text -> IO {| effs} a -> IO {| effs} a -> IO {r *runtimeMaintenanceSecret | effs} a
704+withMaintAuth : Text -> IO effs a -> IO effs a -> IO {r *runtimeMaintenanceSecret | effs} a
666withMaintAuth = \authorization onUnauth onAuth -> do
705withMaintAuth = \authorization onUnauth onAuth -> do
667 authorized <- verifyMaintenanceAuth authorization
706 authorized <- verifyMaintenanceAuth authorization
668 if authorized then onAuth else onUnauth
707 if authorized then onAuth else onUnauth
@@ -701,7 +740,7 @@ gateLookup = \auth ip t mkErr rateResp onAuth ->
701
740
702-- Resolve a Bearer token to its session pubkey if the session is unexpired and
741-- Resolve a Bearer token to its session pubkey if the session is unexpired and
703-- the client IP matches. Runs `onAuth pubkey` on success, `onUnauth` otherwise.
742-- the client IP matches. Runs `onAuth pubkey` on success, `onUnauth` otherwise.
704−withSessionAuth : Text -> Int<Ms> -> Text -> IO {| e} a -> (PubkeyHex -> IO {| e} a) -> IO {r *sessions | e} a
743+withSessionAuth : Text -> Int<Ms> -> Text -> IO e a -> (PubkeyHex -> IO e a) -> IO {r *sessions | e} a
705withSessionAuth = \authorization t clientIp onUnauth onAuth -> do
744withSessionAuth = \authorization t clientIp onUnauth onAuth -> do
706 ses <- *sessions
745 ses <- *sessions
707 case extractToken authorization of
746 case extractToken authorization of
@@ -791,7 +830,10 @@ withFederationAuth = \authorization t mkErr fromServer scope requestTimestamp pr
791-- `authPreCheck` composes global + ip + auth-global into a one-shot
830-- `authPreCheck` composes global + ip + auth-global into a one-shot
792-- gate for unauthenticated handlers (challenge/verify).
831-- gate for unauthenticated handlers (challenge/verify).
793
832
794−-- Returns {limited, entries}. Increments count when the key fits within the window.
833+-- Returns {limited, entries}. Increments count when the key fits within the
834+-- window; otherwise resets to a fresh window starting at `t`. The returned
835+-- `entries` list is what the caller must write back to the corresponding ref;
836+-- it has been capped at 5000 rows (LRU by `windowStart`) to bound memory.
795checkRate : [RateEntry] -> Text -> Int -> Int<Ms> -> Timestamp -> {limited: Bool, entries: [RateEntry]}
837checkRate : [RateEntry] -> Text -> Int -> Int<Ms> -> Timestamp -> {limited: Bool, entries: [RateEntry]}
796checkRate = \entries key maxRate windowMs t -> (\cur -> if cur.count > maxRate then {limited: True {}, entries} else {limited: False {}, entries: capList (union (removeWhere entries (\x -> x.key == key)) [{key, cur.windowStart, cur.count}]) 5000 (\r -> r.windowStart)}) (case findFirst entries (\r -> r.key == key && t - r.windowStart < windowMs) of
838checkRate = \entries key maxRate windowMs t -> (\cur -> if cur.count > maxRate then {limited: True {}, entries} else {limited: False {}, entries: capList (union (removeWhere entries (\x -> x.key == key)) [{key, cur.windowStart, cur.count}]) 5000 (\r -> r.windowStart)}) (case findFirst entries (\r -> r.key == key && t - r.windowStart < windowMs) of
797 Just {value: r} -> {r.windowStart, count: r.count + 1}
839 Just {value: r} -> {r.windowStart, count: r.count + 1}
@@ -910,10 +952,6 @@ clearFailure = \server -> atomic (do
910nextSeqTs : Timestamp -> Timestamp -> Timestamp
952nextSeqTs : Timestamp -> Timestamp -> Timestamp
911nextSeqTs = \t seqCounter -> max t (seqCounter + 1)
953nextSeqTs = \t seqCounter -> max t (seqCounter + 1)
912
954
913−-- Drop duplicates and cap to `maxMessageIds` (defense against bloated requests).
914−dedupMessageIds : [MessageIdHex] -> [MessageIdHex]
915−dedupMessageIds = \ids -> takeRelation maxMessageIds ids
916−
917-- All unexpired presence rows for a given pubkey (may include both local and
955-- All unexpired presence rows for a given pubkey (may include both local and
918-- remote entries — the recipient may live on multiple servers simultaneously).
956-- remote entries — the recipient may live on multiple servers simultaneously).
919presenceForKey : [PresenceEntry] -> PubkeyHex -> Timestamp -> [PresenceEntry]
957presenceForKey : [PresenceEntry] -> PubkeyHex -> Timestamp -> [PresenceEntry]
@@ -1311,6 +1349,10 @@ pollOnce = \authorization cursor cursorWasReset clientIp serverGen t -> do
1311
1349
1312-- One pass: collect events at `t`. If empty, retry-wait on `pollWakeup`
1350-- One pass: collect events at `t`. If empty, retry-wait on `pollWakeup`
1313-- (bumped by send/heartbeat) and re-collect once at the new wall time.
1351-- (bumped by send/heartbeat) and re-collect once at the new wall time.
1352+-- The retry uses Knot's STM `retry` primitive: the transaction blocks until
1353+-- some other transaction commits a change to `*pollWakeup`. Because
1354+-- `pollHeartbeat` bumps `*pollWakeup` every `pollTimeoutMs`, no caller waits
1355+-- longer than one heartbeat period before getting at least an empty response.
1314doPoll : {clampedCursor: Int, pubkey: PubkeyHex, startWakeup: Timestamp} -> Int<Ms> -> IO {r *pollWakeup, clock} {cursor: Int<Ms>, events: [{encryptedBlob: BlobHex}], hasMore: Bool}
1356doPoll : {clampedCursor: Int, pubkey: PubkeyHex, startWakeup: Timestamp} -> Int<Ms> -> IO {r *pollWakeup, clock} {cursor: Int<Ms>, events: [{encryptedBlob: BlobHex}], hasMore: Bool}
1315doPoll = \s t -> do
1357doPoll = \s t -> do
1316 let collectAt = \tnow -> atomic (do
1358 let collectAt = \tnow -> atomic (do
@@ -1326,6 +1368,9 @@ doPoll = \s t -> do
1326
1368
1327-- Pull this pubkey's unexpired messages newer than `cursor`, sort by
1369-- Pull this pubkey's unexpired messages newer than `cursor`, sort by
1328-- receivedAt, take up to `maxPollEvents`, and report `hasMore` if truncated.
1370-- receivedAt, take up to `maxPollEvents`, and report `hasMore` if truncated.
1371+-- The returned cursor is the max `receivedAt` in the page (or the input
1372+-- cursor when the page is empty), which the client echoes on the next /poll
1373+-- to advance — and which the server treats as an implicit ack.
1329collectPollEvents : [Message] -> PubkeyHex -> Timestamp -> Timestamp -> {events: [PollEvent], cursor: Timestamp, hasMore: Bool}
1374collectPollEvents : [Message] -> PubkeyHex -> Timestamp -> Timestamp -> {events: [PollEvent], cursor: Timestamp, hasMore: Bool}
1330collectPollEvents = \messages pubkey cursor t -> (\myMsgs -> (\limited -> {events: pluck limited (\m -> {m.encryptedBlob}), cursor: fold (\acc m -> max acc m.receivedAt) cursor limited, hasMore: count myMsgs > count limited}) (takeRelation maxPollEvents (sortBy (\m -> m.receivedAt) myMsgs))) (filter messages (\m -> m.toKey == pubkey && t - m.receivedAt < messageMaxAge && m.receivedAt > cursor))
1375collectPollEvents = \messages pubkey cursor t -> (\myMsgs -> (\limited -> {events: pluck limited (\m -> {m.encryptedBlob}), cursor: fold (\acc m -> max acc m.receivedAt) cursor limited, hasMore: count myMsgs > count limited}) (takeRelation maxPollEvents (sortBy (\m -> m.receivedAt) myMsgs))) (filter messages (\m -> m.toKey == pubkey && t - m.receivedAt < messageMaxAge && m.receivedAt > cursor))
1331
1376
@@ -1377,6 +1422,10 @@ handleSendMessages = \messages authorization clientIp -> do
1377-- presence rows that already exist for `to`. Returns the error code (empty
1422-- presence rows that already exist for `to`. Returns the error code (empty
1378-- on success or silent duplicate) and the list of remote servers the caller
1423-- on success or silent duplicate) and the list of remote servers the caller
1379-- should kick forwards to.
1424-- should kick forwards to.
1425+-- All three rate counters are charged unconditionally (even on a failure)
1426+-- so that a sender can't probe limits cheaply by sending to themselves or
1427+-- replaying duplicates. Order matters: send-rate is the sender's identity,
1428+-- pair-rate covers conversation spam, and recv-rate is per-recipient.
1380sendMessageCore : PubkeyHex -> PubkeyHex -> BlobHex -> Int<Ms> -> IO {r *deliveredIds, rw *forwards, rw *messages, rw *pairRates, rw *pollWakeup, r *presence, rw *recvRates, rw *sendRates, rw *seqCounter} {remoteServers: [Text], error: Text}
1429sendMessageCore : PubkeyHex -> PubkeyHex -> BlobHex -> Int<Ms> -> IO {r *deliveredIds, rw *forwards, rw *messages, rw *pairRates, rw *pollWakeup, r *presence, rw *recvRates, rw *sendRates, rw *seqCounter} {remoteServers: [Text], error: Text}
1381sendMessageCore = \senderKey to encryptedBlob t -> do
1430sendMessageCore = \senderKey to encryptedBlob t -> do
1382 msgs <- *messages
1431 msgs <- *messages
@@ -1518,26 +1567,9 @@ handleMessageStatus = \to messageId authorization clientIp -> do
1518 yield {status: s, authorized: True {}})
1567 yield {status: s, authorized: True {}})
1519
1568
1520-- ============================================
1569-- ============================================
1521−-- Handler: ack / list / delete
1570+-- Handler: list
1522-- ============================================
1571-- ============================================
1523−-- Mailbox mutation by the recipient (ack, list, delete).
1572+-- Read-only mailbox peek by the recipient.
1524−
1525−-- POST /messages/ack: explicit ack by message id (alternative to the implicit
1526−-- cursor-based ack in /poll). Drops mailbox+forward rows.
1527−handleAckMessages : [MessageIdHex] -> Text -> Text -> IO {rw *globalRateCount, rw *globalRateWindowStart, r *sessions, clock} {ackedCount: Int, authorized: Bool, error: Text, ok: Bool}
1528−handleAckMessages = \messageIds authorization clientIp -> do
1529− t <- now
1530− let mkErr = \e -> {ok: False {}, authorized: False {}, ackedCount: 0, error: e}
1531− gateClientWrite authorization clientIp t mkErr {
1532− ok: False {},
1533− authorized: True {},
1534− ackedCount: 0,
1535− error: "rate_limited"
1536− } (\pubkey -> do
1537− ackedCount <- atomic (do
1538− toAck <- removeForKey (\x -> x.toKey == pubkey && elem (messageIdOf x.encryptedBlob) (dedupMessageIds messageIds))
1539− yield (count toAck))
1540− yield {ok: True {}, authorized: True {}, ackedCount, error: ""})
1541
1573
1542-- POST /messages/list: paginated peek at the caller's mailbox without acking.
1574-- POST /messages/list: paginated peek at the caller's mailbox without acking.
1543-- Returns metadata only (no ciphertext) plus the total count.
1575-- Returns metadata only (no ciphertext) plus the total count.
@@ -1568,23 +1600,6 @@ handleListMessages = \offset authorization clientIp -> do
1568 })
1600 })
1569 yield {messages: page, authorized: True {}, count: total, error: ""})
1601 yield {messages: page, authorized: True {}, count: total, error: ""})
1570
1602
1571−-- POST /messages/delete: same effect as ack from the recipient's side, with
1572−-- a "delete" semantic for the response.
1573−handleDeleteMessages : [MessageIdHex] -> Text -> Text -> IO {rw *globalRateCount, rw *globalRateWindowStart, r *sessions, clock} {authorized: Bool, deletedCount: Int, error: Text, ok: Bool}
1574−handleDeleteMessages = \messageIds authorization clientIp -> do
1575− t <- now
1576− let mkErr = \e -> {ok: False {}, authorized: False {}, deletedCount: 0, error: e}
1577− gateClientWrite authorization clientIp t mkErr {
1578− ok: False {},
1579− authorized: True {},
1580− deletedCount: 0,
1581− error: "rate_limited"
1582− } (\pubkey -> do
1583− deletedCount <- atomic (do
1584− toDel <- removeForKey (\x -> x.toKey == pubkey && elem (messageIdOf x.encryptedBlob) (dedupMessageIds messageIds))
1585− yield (count toDel))
1586− yield {ok: True {}, authorized: True {}, deletedCount, error: ""})
1587−
1588-- ============================================
1603-- ============================================
1589-- Handler: federation receive
1604-- Handler: federation receive
1590-- ============================================
1605-- ============================================
@@ -2327,9 +2342,7 @@ api = serve Api where
2327 Poll = \{cursor, generation, authorization, clientIp} -> handlePoll cursor generation authorization clientIp
2342 Poll = \{cursor, generation, authorization, clientIp} -> handlePoll cursor generation authorization clientIp
2328 SendMessages = \{messages, authorization, clientIp} -> handleSendMessages messages authorization clientIp
2343 SendMessages = \{messages, authorization, clientIp} -> handleSendMessages messages authorization clientIp
2329 MessageStatus = \{to, messageId, authorization, clientIp} -> handleMessageStatus to messageId authorization clientIp
2344 MessageStatus = \{to, messageId, authorization, clientIp} -> handleMessageStatus to messageId authorization clientIp
2330− AckMessages = \{messageIds, authorization, clientIp} -> handleAckMessages messageIds authorization clientIp
2331 ListMessages = \{offset, authorization, clientIp} -> handleListMessages offset authorization clientIp
2345 ListMessages = \{offset, authorization, clientIp} -> handleListMessages offset authorization clientIp
2332− DeleteMessages = \{messageIds, authorization, clientIp} -> handleDeleteMessages messageIds authorization clientIp
2333 LookupPresence = \{pubkey, authorization, clientIp} -> handleLookupPresence pubkey authorization clientIp
2346 LookupPresence = \{pubkey, authorization, clientIp} -> handleLookupPresence pubkey authorization clientIp
2334 BatchLookupPresence = \{pubkeys, authorization, clientIp} -> handleBatchLookupPresence pubkeys authorization clientIp
2347 BatchLookupPresence = \{pubkeys, authorization, clientIp} -> handleBatchLookupPresence pubkeys authorization clientIp
2335 RecvGossip = \{events, fromServer, requestTimestamp, authorization} -> handleRecvGossip events fromServer requestTimestamp authorization
2348 RecvGossip = \{events, fromServer, requestTimestamp, authorization} -> handleRecvGossip events fromServer requestTimestamp authorization
@@ -2401,7 +2414,7 @@ pollHeartbeat = do
2401
2414
2402-- Entry point: stamp startup metadata, lazily generate the federation keypair
2415-- Entry point: stamp startup metadata, lazily generate the federation keypair
2403-- on first run, fork the background loops, and start the HTTP listener.
2416-- on first run, fork the background loops, and start the HTTP listener.
2404−main : IO {| _} {}
2417+main : IO _ {}
2405main = do
2418main = do
2406 t <- now
2419 t <- now
2407 genJitter <- randomInt 1000000
2420 genJitter <- randomInt 1000000