@@ -239,9 +239,9 @@ type ServerName = Text where isValidServerName
239239type Timestamp = Int<Ms>
240240
241241-- Outstanding nonce issued by /auth/challenge; consumed by /auth/verify.
242−type Challenge = {pubkey: Text, challenge: Text, expiresAt: Timestamp, ip: Text}
242+type Challenge = {pubkey: PubkeyHex, challenge: Text, expiresAt: Timestamp, ip: Text}
243243-- An issued bearer token bound to (pubkey, ip, expiry).
244−type Session = {token: Text, pubkey: Text, expiresAt: Timestamp, ip: Text}
244+type Session = {token: Text, pubkey: PubkeyHex, expiresAt: Timestamp, ip: Text}
245245-- Mailbox row: a stored message awaiting recipient pickup or federation forward.
246246-- Storage TTL is fixed at `messageMaxAge`, so expiry is derived from
247247-- `receivedAt`. Any send time the sender wants to convey lives inside
@@ -250,14 +250,18 @@ type Session = {token: Text, pubkey: Text, expiresAt: Timestamp, ip: Text}
250250-- presence re-announce of `toKey` doesn't trigger a duplicate delivery.
251251-- (Single-field record rows, not bare names: the Knot runtime cannot persist
252252-- a nested relation of scalars.)
253−type Message = {toKey: Text, encryptedBlob: BlobHex, receivedAt: Timestamp, deliveredTo: [{server: Text}]}
253+-- Server names are plain `Text`, not `ServerName`: they are sourced from
254+-- `serverHost`, which is a startup-overridable scalar the predicate cannot be
255+-- checked on at declaration time (see `localServerName`). Pubkeys are refined —
256+-- every write site can prove the predicate.
257+type Message = {toKey: PubkeyHex, encryptedBlob: BlobHex, receivedAt: Timestamp, deliveredTo: [{server: Text}]}
254258-- Last-known location of a pubkey, either local (`server == serverHost`) or
255259-- learned from a peer via gossip. Multiple entries per pubkey are allowed.
256−type PresenceEntry = {pubkey: Text, server: Text, expiresAt: Timestamp}
260+type PresenceEntry = {pubkey: PubkeyHex, server: Text, expiresAt: Timestamp}
257261-- A message queued for cross-server delivery to `toServer`. `retries` is
258262-- incremented by `retryPendingForwards` until `maxForwardRetries` is hit.
259263-- Identity is derived from `encryptedBlob` (see `Message`).
260−type ForwardEntry = {toServer: Text, toKey: Text, encryptedBlob: BlobHex, retries: Int}
264+type ForwardEntry = {toServer: Text, toKey: PubkeyHex, encryptedBlob: BlobHex, retries: Int}
261265-- Running failure counter for a peer; keyed by `server`. Drives `shouldRetryServer`.
262266-- `failedAt` is the start time of the last recorded attempt; `failures: 0` is
263267-- the post-success tombstone (no backoff, kept so the timestamp can outrank
@@ -688,14 +692,12 @@ hasLocalPresence = \pres pk t -> any (\p -> p.pubkey == pk && isLocalServer p.se
688692-- Project items through `getServer`, drop our own hostname, and dedupe.
689693-- Used to derive peer recipient lists from forwards/presence/etc.
690694--
691−-- Returns `[ServerName]`, not `[Text]`: every caller projects a field that is
692−-- already a `ServerName` (`PresenceEntry.server`, `ForwardEntry.toServer`), so
693−-- Returns `[Text]` — the base type of `ServerName`. Internal helpers use `Text`
694−-- (the base type); the refined `ServerName` is enforced at route boundaries
695−-- (`fromServer: ServerName` on `/federation/gossip`) and in relation schemas.
696−-- Downstream functions that build records with `ServerName` fields (e.g.
697−-- `forwardMatches`, `forwardQueuedToServer`) accept `Text` and the runtime
698−-- validates the refined type on relation write.
695+-- Returns `[Text]`, the base type of `ServerName`. Server names stay unrefined
696+-- internally: they originate in `serverHost`, a startup-overridable scalar whose
697+-- predicate can only be checked at runtime (`localServerName`). The refined
698+-- `ServerName` is enforced where it can be — at the `/federation/gossip` route
699+-- boundary (`fromServer: ServerName`) and at federation egress, where
700+-- `isBadServerName` applies the SSRF deny-list the predicate deliberately omits.
699701collectPeers : [a] -> (a -> Text) -> [Text]
700702collectPeers = \items getServer -> filter (\s -> not isLocalServer s) (map getServer items)
701703
@@ -725,7 +727,7 @@ forwardMatches = \blob server f -> f.encryptedBlob == blob && f.toServer == serv
725727-- Replace any local presence rows for `pubkey` with one fresh row at
726728-- `expiry`, and return the post-update peer broadcast set. Caller wraps in
727729-- `atomic`.
728−refreshLocalPresence : Text -> Timestamp -> IO {rw *presence} [Text]
730+refreshLocalPresence : PubkeyHex -> Timestamp -> IO {rw *presence} [Text]
729731refreshLocalPresence = \pubkey expiry -> do
730732 pres <- *presence
731733 *presence = union (filter (\p -> not (p.pubkey == pubkey && isLocalServer p.server)) pres) [
@@ -743,7 +745,7 @@ refreshLocalPresence = \pubkey expiry -> do
743745-- One sweep over every state ref: drops expired rows in a single atomic block.
744746-- Returns the locally-expired presence rows whose pubkey has no other live
745747-- local session, so the caller can broadcast offline gossip for them.
746−pruneExpired : Int<Ms> -> IO {rw *challenges, rw *forwardFailures, rw *messages, rw *presence, rw *sessions} [Text]
748+pruneExpired : Int<Ms> -> IO {rw *challenges, rw *forwardFailures, rw *messages, rw *presence, rw *sessions} [PubkeyHex]
747749pruneExpired = \t -> atomic do
748750 ch <- *challenges
749751 *challenges = filter (\x -> x.expiresAt > t) ch
@@ -851,7 +853,7 @@ retryPendingForwards = do
851853-- and why it is an accepted v0.1 limitation. Do not add a caller that widens
852854-- what this forwards (e.g. dropping the `deliveredTo` filter) without revisiting
853855-- that note.
854−forwardQueuedToServer : Text -> ServerName -> IO {rw *forwards, r *messages} {}
856+forwardQueuedToServer : PubkeyHex -> ServerName -> IO {rw *forwards, r *messages} {}
855857forwardQueuedToServer = \pubkey server -> atomic (do
856858 msgs <- *messages
857859 fwds <- *forwards
@@ -984,7 +986,7 @@ handlePoll = \cursor authorization clientIp -> do
984986-- the wait transaction reads `*messages` and `retry`s until a row matching
985987-- this pubkey/cursor is committed; the timer wins on idle. Either way we
986988-- re-collect at the new wall time, so a timeout returns an empty page.
987−doPoll : {clampedCursor: Timestamp, pubkey: Text | a} -> Timestamp -> IO {r *messages, clock} {cursor: Timestamp, events: [{encryptedBlob: BlobHex}]}
989+doPoll : {clampedCursor: Timestamp, pubkey: PubkeyHex | a} -> Timestamp -> IO {r *messages, clock} {cursor: Timestamp, events: [{encryptedBlob: BlobHex}]}
988990doPoll = \s t -> do
989991 let collectAt = \tnow -> atomic (do
990992 msgs <- *messages
@@ -1182,21 +1184,24 @@ handleRecvGossip = \events fromServer -> do
11821184 pres <- *presence
11831185 let collected = fold (\acc e -> case e.pubkey of
11841186 -- `pubkey` is nested inside the `events` list, so the route-boundary
1185− -- refinement never validates it (same nested-list gap that `/messages`
1186− -- works around with an explicit `isValidExactHex 64` check). Reject a
1187− -- malformed key here so a peer can't poison `*presence` with non-hex
1188− -- rows that then spawn bogus-`toKey` forward entries.
1189− Just {value: pk} -> if not isValidExactHex 64 pk
1190− then acc
1191− else if e.eventType == "online"
1192− then {
1193− touched: union acc.touched [pk],
1194− added: union acc.added [{pubkey: pk, server: fromServer, expiresAt: t + onlineGossipTtl}]
1195− }
1196− else if e.eventType == "offline"
1197− then {acc.added, touched: union acc.touched [pk]}
1198− else acc
1199− Nothing {} -> acc) {touched: ([] : [Text]), added: ([] : [PresenceEntry])} events
1187+ -- refinement never validates it (the same nested-list gap that
1188+ -- `/messages` works around by hand). `refine` re-checks the `PubkeyHex`
1189+ -- predicate here, dropping a malformed key so a peer can't poison
1190+ -- `*presence` with non-hex rows that then spawn bogus-`toKey` forward
1191+ -- entries. It also yields the `PubkeyHex` the `PresenceEntry` row below
1192+ -- requires — an explicit `isValidExactHex 64` guard would check the same
1193+ -- predicate but leave the key typed `Text`.
1194+ Just {value: raw} -> case ((refine raw) : Result RefinementError PubkeyHex) of
1195+ Err _ -> acc
1196+ Ok {value: pk} -> if e.eventType == "online"
1197+ then {
1198+ touched: union acc.touched [pk],
1199+ added: union acc.added [{pubkey: pk, server: fromServer, expiresAt: t + onlineGossipTtl}]
1200+ }
1201+ else if e.eventType == "offline"
1202+ then {acc.added, touched: union acc.touched [pk]}
1203+ else acc
1204+ Nothing {} -> acc) {touched: ([] : [PubkeyHex]), added: ([] : [PresenceEntry])} events
12001205 let kept = filter (\p -> p.server != fromServer || not elem p.pubkey collected.touched) pres
12011206 *presence = union kept collected.added
12021207 yield (filter (\pk -> not any (\p -> p.pubkey == pk && p.server == fromServer && p.expiresAt > t) pres) (map (\p -> p.pubkey) collected.added)))