@@ -59,9 +59,6 @@ maxBlobLen = 41943040
5959-- Length of a hex-encoded Ed25519 signature (64 bytes → 128 hex).
6060signatureLen : Int
6161signatureLen = 128
62−-- Cap on the number of message IDs accepted in a single ack/lookup request.
63−maxMessageIds : Int
64−maxMessageIds = 100
6562-- Cap on items in a batched send request.
6663maxBatchSize : Int
6764maxBatchSize = 100
@@ -211,14 +208,6 @@ federationUrl = \server -> federationScheme ++ server
211208requireClientIp : Bool
212209requireClientIp = True {}
213210
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−
222211-- ============================================
223212-- Types
224213-- ============================================
@@ -307,9 +296,13 @@ type DeliveredEntry = {messageId: MessageIdHex, toKey: PubkeyHex, deliveredAt: T
307296-- Running failure counter for a peer; keyed by `server`. Drives `retryBackoff`.
308297type ForwardFailure = {server: Text, failedAt: Timestamp, failures: Int, firstFailedAt: Timestamp}
309298-- 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).
311302type RateEntry = {key: Text, windowStart: Timestamp, count: Int}
312303-- 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).
313306type ActivePoll = {pubkey: PubkeyHex, startedAt: Timestamp}
314307
315308-- ============================================
@@ -319,46 +312,81 @@ type ActivePoll = {pubkey: PubkeyHex, startedAt: Timestamp}
319312-- scan is O(n); scaling is the first thing the production version replaces.
320313-- Grouped here: auth (challenges/sessions), mailbox (messages/forwards/acks),
321314-- 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.
322318
319+-- Outstanding /auth/challenge nonces awaiting /auth/verify. Capped overall and per-pubkey.
323320*challenges : [Challenge]
321+-- Live bearer tokens. Source of truth for `withSessionAuth`; trimmed by `pruneExpired`.
324322*sessions : [Session]
323+-- Mailbox: ciphertexts addressed to local recipients, awaiting poll or federation forward.
325324*messages : [Message]
325+-- Last-known location of each pubkey (local sessions and gossiped remote presence).
326326*presence : [PresenceEntry]
327+-- Cross-server outbox: messages queued for delivery to a peer, with retry counts.
327328*forwards : [ForwardEntry]
329+-- Tombstones of already-delivered message ids; combined with `*messages` for dedup.
328330*deliveredIds : [DeliveredEntry]
331+-- Per-peer failure counters; drives `retryBackoff` and the federation health view.
329332*forwardFailures : [ForwardFailure]
333+-- Currently-blocking long-poll slots, used to cap concurrent /poll holds per pubkey.
330334*activePolls : [ActivePoll]
331335
336+-- Per-key fixed-window counters for /messages send-side rate limiting.
332337*sendRates : [RateEntry]
338+-- Per-recipient counters; protects a popular key from being flooded by many senders.
333339*recvRates : [RateEntry]
340+-- Per-(sender, recipient) pair counters; second-axis brake on chat spam.
334341*pairRates : [RateEntry]
342+-- Per-key /auth/challenge counters (separate from `*challenges` capacity gate).
335343*challengeRates : [RateEntry]
344+-- Per-key counters for read-only lookups (presence, /messages/list).
336345*lookupRates : [RateEntry]
346+-- Per-key counters for misc client writes (/auth/revoke, /auth/logout, /messages/status).
337347*clientWriteRates : [RateEntry]
348+-- Per-peer counters for inbound federation calls.
338349*federationRates : [RateEntry]
350+-- Per-source-IP counters; gate for unauthenticated traffic.
339351*ipRates : [RateEntry]
340352
353+-- Global request-rate gate (single counter shared across all clients).
341354*globalRateCount : Int
342355*globalRateWindowStart : Timestamp
356+-- Global gate charged only by /auth/* endpoints, on top of `*globalRateCount`.
343357*authGlobalRateCount : Int
344358*authGlobalRateWindowStart : Timestamp
345359
360+-- Strictly-monotonic sequence used as `Message.receivedAt`; guarantees poll-cursor ordering.
346361*seqCounter : Timestamp
362+-- Bumped by send/heartbeat to wake all blocked /poll holders.
347363*pollWakeup : Timestamp
364+-- Wall time of the last completed prune sweep (for /health and /ready).
348365*lastPrunedAt : Timestamp
366+-- Wall time of the last poll-heartbeat tick (for /health watchdog).
349367*lastHeartbeatAt : Timestamp
368+-- Wall time of the last self-presence refresh broadcast to peers.
350369*lastPresenceRefresh : Timestamp
370+-- Wall time of process start; underpins /stats uptime and /health warmup window.
351371*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.
352374*serverGeneration : Int
353375
376+-- Bearer secret accepted on /maintenance/* endpoints. Rotated via /maintenance/rotate-secret;
377+-- empty value disables maintenance entirely (every check returns false).
354378*runtimeMaintenanceSecret : Text
355379
380+-- Ed25519 federation keypair (hex). Generated on first startup if blank, then persisted.
356381*federationPrivateKeyHex : Text
357382*federationPublicKeyHex : Text
358383
384+-- Operator-managed peer deny-list; checked on every inbound and outbound federation call.
359385*blockedPeers : [Text]
386+-- Operator-managed allow-list. When non-empty, only listed peers may federate (whitelist mode).
360387*allowedPeers : [Text]
361388
389+-- Cumulative tick counters surfaced on /stats; bumped by the background loops.
362390*pruneCount : Int
363391*heartbeatCount : Int
364392
@@ -385,9 +413,7 @@ route ClientApi where
385413 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
386414 POST {messages: [{to: PubkeyHex, encryptedBlob: BlobHex}]} /messages headers {authorization: Text, clientIp: Text} -> {results: [{messageId: MessageIdHex, error: Text}], error: Text, retryAfterMs: Int} = SendMessages
387415 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
389416 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
391417 GET /lookup/{pubkey: PubkeyHex} headers {authorization: Text, clientIp: Text} -> {server: ServerName, authorized: Bool, error: Text} = LookupPresence
392418 POST {pubkeys: [PubkeyHex]} /lookup/batch headers {authorization: Text, clientIp: Text} -> {results: [{pubkey: PubkeyHex, server: ServerName}], authorized: Bool, error: Text} = BatchLookupPresence
393419
@@ -467,35 +493,44 @@ route Api = AuthApi | ClientApi | FederationApi | FederationPubkeyApi | Maintena
467493-- Everything is intentionally O(n) over flat lists; performance is sacrificed
468494-- for clarity per the file header.
469495
496+-- Keep elements satisfying `pred`. Uses Knot's relation comprehension (`where`).
470497filter : a b -> (b -> Bool) -> a b
471498filter = \xs pred -> do
472499 x <- xs
473500 where pred x
474501 yield x
502+-- Inverse of `filter`: drop elements satisfying `pred`.
475503removeWhere : a b -> (b -> Bool) -> a b
476504removeWhere = \xs pred -> filter xs (\x -> not pred x)
505+-- Map without filtering (a.k.a. classic `map`).
477506pluck : a b -> (b -> c) -> a c
478507pluck = \xs f -> do
479508 x <- xs
480509 yield (f x)
510+-- Filter then map in one pass.
481511mapWhere : a b -> (b -> Bool) -> (b -> c) -> a c
482512mapWhere = \xs pred -> pluck (filter xs pred)
483513countWhere : a b -> (b -> Bool) -> Int
484514countWhere = \xs pred -> count (filter xs pred)
515+-- True when at least one element matches.
485516any : [a] -> (a -> Bool) -> Bool
486517any = \xs pred -> countWhere xs pred > 0
518+-- True when no element matches.
487519none : [a] -> (a -> Bool) -> Bool
488520none = \xs pred -> not any xs pred
521+-- True when every element matches.
489522all : [a] -> (a -> Bool) -> Bool
490523all = \xs pred -> none xs (\x -> not pred x)
491524findFirst : [a] -> (a -> Bool) -> Maybe a
492525findFirst = \xs pred -> headOf (filter xs pred)
526+-- Run `action` only when `cond` is True; otherwise no-op.
493527when : Bool -> IO {} {} -> IO {} {}
494528when = \cond action -> if cond then action else yield {}
495529min : a -> a -> a
496530min = \a b -> if a < b then a else b
497531max : a -> a -> a
498532max = \a b -> if a > b then a else b
533+-- Confine `x` to the closed interval [lo, hi].
499534clamp : a -> a -> a -> a
500535clamp = \lo hi x -> max lo (min hi x)
501536abs : Int -> Int
@@ -509,6 +544,8 @@ headOf : [a] -> Maybe a
509544headOf = \items -> fold (\_ x -> Just {value: x}) (Nothing {}) (takeRelation 1 items)
510545
511546-- 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.
512549capList : [a] -> Int -> (a -> b) -> [a]
513550capList = \items maxN getKey -> dropRelation (max 0 (count items - maxN)) (sortBy getKey items)
514551
@@ -516,16 +553,10 @@ capList = \items maxN getKey -> dropRelation (max 0 (count items - maxN)) (sortB
516553pct : Int -> Int -> Int
517554pct = \cur maxN -> if maxN > 0 then cur * 100 / maxN else 0
518555
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`.
529560capPerKey : [a] -> (a -> Bool) -> Int -> (a -> b) -> [a]
530561capPerKey = \items pred maxN getKey -> union (removeWhere items pred) (capList (filter items pred) (maxN - 1) getKey)
531562
@@ -545,7 +576,8 @@ emptyPollResp = \cursor authorized rateLimited gen retryAfterMs cursorWasReset -
545576 loadPct: 0
546577}
547578
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.
549581forEach : [a] -> (a -> IO {} {}) -> IO {} {}
550582forEach = \items action -> case single (takeRelation 1 items) of
551583 Nothing {} -> yield {}
@@ -562,7 +594,8 @@ mapEach = \items action -> case headOf items of
562594 rest <- mapEach (dropRelation 1 items) action
563595 yield (union [r] rest)
564596
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).
566599genTokenN : Int -> IO {random} Text
567600genTokenN = \n -> if n <= 0
568601then yield ""
@@ -571,7 +604,8 @@ else do
571604 rest <- genTokenN (n - 1)
572605 yield (take 1 (drop d "0123456789abcdef") ++ rest)
573606
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).
575609genToken : IO {random} Text
576610genToken = genTokenN 48
577611
@@ -619,7 +653,12 @@ containsSuffix : Text -> Text -> Bool
619653containsSuffix = \suffix s -> drop (max 0 (count (chars s) - count (chars suffix))) s == suffix
620654
621655-- 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`).
623662isBadServerName : Text -> Bool
624663isBadServerName = \sn -> sn == "" || sn == "localhost" || not all (chars sn) (\c -> contains c "0123456789abcdefghijklmnopqrstuvwxyz-.:") || any [
625664 ".",
@@ -662,7 +701,7 @@ verifyMaintenanceAuth = \authorization -> do
662701
663702-- Branch on maintenance-secret auth: run `onAuth` when the bearer matches,
664703-- `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
666705withMaintAuth = \authorization onUnauth onAuth -> do
667706 authorized <- verifyMaintenanceAuth authorization
668707 if authorized then onAuth else onUnauth
@@ -701,7 +740,7 @@ gateLookup = \auth ip t mkErr rateResp onAuth ->
701740
702741-- Resolve a Bearer token to its session pubkey if the session is unexpired and
703742-- 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
705744withSessionAuth = \authorization t clientIp onUnauth onAuth -> do
706745 ses <- *sessions
707746 case extractToken authorization of
@@ -791,7 +830,10 @@ withFederationAuth = \authorization t mkErr fromServer scope requestTimestamp pr
791830-- `authPreCheck` composes global + ip + auth-global into a one-shot
792831-- gate for unauthenticated handlers (challenge/verify).
793832
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.
795837checkRate : [RateEntry] -> Text -> Int -> Int<Ms> -> Timestamp -> {limited: Bool, entries: [RateEntry]}
796838checkRate = \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
797839 Just {value: r} -> {r.windowStart, count: r.count + 1}
@@ -910,10 +952,6 @@ clearFailure = \server -> atomic (do
910952nextSeqTs : Timestamp -> Timestamp -> Timestamp
911953nextSeqTs = \t seqCounter -> max t (seqCounter + 1)
912954
913−-- Drop duplicates and cap to `maxMessageIds` (defense against bloated requests).
914−dedupMessageIds : [MessageIdHex] -> [MessageIdHex]
915−dedupMessageIds = \ids -> takeRelation maxMessageIds ids
916−
917955-- All unexpired presence rows for a given pubkey (may include both local and
918956-- remote entries — the recipient may live on multiple servers simultaneously).
919957presenceForKey : [PresenceEntry] -> PubkeyHex -> Timestamp -> [PresenceEntry]
@@ -1311,6 +1349,10 @@ pollOnce = \authorization cursor cursorWasReset clientIp serverGen t -> do
13111349
13121350-- One pass: collect events at `t`. If empty, retry-wait on `pollWakeup`
13131351-- (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.
13141356doPoll : {clampedCursor: Int, pubkey: PubkeyHex, startWakeup: Timestamp} -> Int<Ms> -> IO {r *pollWakeup, clock} {cursor: Int<Ms>, events: [{encryptedBlob: BlobHex}], hasMore: Bool}
13151357doPoll = \s t -> do
13161358 let collectAt = \tnow -> atomic (do
@@ -1326,6 +1368,9 @@ doPoll = \s t -> do
13261368
13271369-- Pull this pubkey's unexpired messages newer than `cursor`, sort by
13281370-- 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.
13291374collectPollEvents : [Message] -> PubkeyHex -> Timestamp -> Timestamp -> {events: [PollEvent], cursor: Timestamp, hasMore: Bool}
13301375collectPollEvents = \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))
13311376
@@ -1377,6 +1422,10 @@ handleSendMessages = \messages authorization clientIp -> do
13771422-- presence rows that already exist for `to`. Returns the error code (empty
13781423-- on success or silent duplicate) and the list of remote servers the caller
13791424-- 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.
13801429sendMessageCore : 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}
13811430sendMessageCore = \senderKey to encryptedBlob t -> do
13821431 msgs <- *messages
@@ -1518,26 +1567,9 @@ handleMessageStatus = \to messageId authorization clientIp -> do
15181567 yield {status: s, authorized: True {}})
15191568
15201569-- ============================================
1521−-- Handler: ack / list / delete
1570+-- Handler: list
15221571-- ============================================
1523−-- Mailbox mutation by the recipient (ack, list, delete).
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: ""})
1572+-- Read-only mailbox peek by the recipient.
15411573
15421574-- POST /messages/list: paginated peek at the caller's mailbox without acking.
15431575-- Returns metadata only (no ciphertext) plus the total count.
@@ -1568,23 +1600,6 @@ handleListMessages = \offset authorization clientIp -> do
15681600 })
15691601 yield {messages: page, authorized: True {}, count: total, error: ""})
15701602
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−
15881603-- ============================================
15891604-- Handler: federation receive
15901605-- ============================================
@@ -2327,9 +2342,7 @@ api = serve Api where
23272342 Poll = \{cursor, generation, authorization, clientIp} -> handlePoll cursor generation authorization clientIp
23282343 SendMessages = \{messages, authorization, clientIp} -> handleSendMessages messages authorization clientIp
23292344 MessageStatus = \{to, messageId, authorization, clientIp} -> handleMessageStatus to messageId authorization clientIp
2330− AckMessages = \{messageIds, authorization, clientIp} -> handleAckMessages messageIds authorization clientIp
23312345 ListMessages = \{offset, authorization, clientIp} -> handleListMessages offset authorization clientIp
2332− DeleteMessages = \{messageIds, authorization, clientIp} -> handleDeleteMessages messageIds authorization clientIp
23332346 LookupPresence = \{pubkey, authorization, clientIp} -> handleLookupPresence pubkey authorization clientIp
23342347 BatchLookupPresence = \{pubkeys, authorization, clientIp} -> handleBatchLookupPresence pubkeys authorization clientIp
23352348 RecvGossip = \{events, fromServer, requestTimestamp, authorization} -> handleRecvGossip events fromServer requestTimestamp authorization
@@ -2401,7 +2414,7 @@ pollHeartbeat = do
24012414
24022415-- Entry point: stamp startup metadata, lazily generate the federation keypair
24032416-- on first run, fork the background loops, and start the HTTP listener.
2404−main : IO {| _} {}
2417+main : IO _ {}
24052418main = do
24062419 t <- now
24072420 genJitter <- randomInt 1000000