shomei-server-0.3.0.0: src/Shomei/Server/Boot.hs
-- | Startup sequence and warp boot for the standalone Shōmei auth service.
--
-- 'main' runs the turnkey startup: load config → run migrations (idempotent) → acquire the
-- hasql pool → bootstrap the signing key → build the 'Env' → serve with warp. 'application'
-- builds the WAI app from the exact 'Shomei.Servant.Server.shomeiRoutes' tree and the
-- @AuthProtect "shomei-jwt"@ 'Context'. EP-5's handlers run in the smaller servant port
-- stack ('Shomei.Servant.Seam.AppEffects'); they are bridged onto this server's larger
-- PostgreSQL stack ('Shomei.Server.App.AppEffects') with @inject@ inside the seam env's
-- runner. Infrastructure failures remain typed through the seam; domain failures flow
-- through the same channel to the right status.
module Shomei.Server.Boot
( main,
application,
HostBackgroundTasks (..),
installHostBackgroundTasks,
hostMiddleware,
edgeMiddleware,
buildEnv,
seamEnv,
authContext,
validateOidcIssuer,
NotifierWorker (..),
installNotifierWorker,
)
where
-- 'Context' is hidden from the prelude (it re-exports lens's 'Context'); we mean
-- servant's 'Servant.Context' here.
import Control.Concurrent (forkIO, killThread)
import Data.Aeson ((.=))
import Data.Aeson.Key qualified as Key
import Data.ByteString qualified as BS
import Data.Foldable (traverse_)
import Data.IORef (newIORef, readIORef)
import Data.List.NonEmpty qualified as NonEmpty
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Time (DiffTime, picosecondsToDiffTime)
import Database.PostgreSQL.Migrate (MigrationReport (..))
import Effectful (Eff, inject, runEff)
import Effectful.Error.Static (runErrorNoCallStack)
import GHC.Clock (getMonotonicTimeNSec)
import Network.HTTP.Client.TLS (newTlsManager)
import Network.Wai (Application, Middleware, Request)
import Network.Wai.Handler.Warp qualified as Warp
import Servant
( Context (EmptyContext, (:.)),
ErrorFormatters,
serveWithContext,
)
import Servant.Health (ProbeCheck)
import Servant.Server.Experimental.Auth (AuthHandler)
import Shomei.Account.Password.Hash.Postgres (Argon2Failure (..), Argon2Params (..), argon2WarningFloor, hashingLimit, newHashingLimiter, sha256Hex, trialArgon2Derivation)
import Shomei.Audit.Publisher.Postgres (runAuthEventPublisherPostgres)
import Shomei.Authorization.Claims.Domain (Issuer (..), Role (..))
import Shomei.Authorization.Role.Postgres (runRoleStorePostgres)
import Shomei.Authorization.Role.Workflow (undefinedDefaultRoles)
import Shomei.Config (OAuthConfig (..), ObservabilityConfig (..), ShomeiConfig (..), SigningKeyConfig (..), TotpConfig (..), configSigningAlgorithm)
import Shomei.Error (AuthError)
import Shomei.Health.Server (buildHealthChecks)
import Shomei.Mfa.Totp.Postgres (TotpEncryptionKey, checkTotpEnvelopeVersions, totpEncryptionKeyFromBase64, totpEncryptionKeyFromBytes)
import Shomei.Migrations (applyShomeiMigrations)
import Shomei.Notify (deliverNotification)
import Shomei.Notify.Queue (closeNotifierQueue, drainNotifierQueue, newNotifierQueue, withDequeued)
import Shomei.Persistence.Database.Postgres (TransactionRetryPolicy (..), runDatabasePoolWith)
import Shomei.Persistence.Maintenance.Postgres (sweepOnce, sweepReportCounts)
import Shomei.Persistence.Pool.Postgres (acquirePool)
import Shomei.Persistence.Pool.Postgres qualified as Pool
-- '(.=)' is hidden from the prelude (it re-exports lens's state-setter of the same name);
-- we mean aeson's JSON pair constructor here.
import Shomei.Prelude hiding (Context, (.=))
import Shomei.Servant.Api (shomeiRoutesApi, shomeiThrottledRoutes)
import Shomei.Servant.Auth (AuthUser, authHandler)
import Shomei.Servant.Error (shomeiErrorFormatters)
import Shomei.Servant.Middleware (problemMiddleware)
import Shomei.Servant.Oidc (isAbsoluteHttpUrl)
import Shomei.Servant.Seam qualified as Seam
import Shomei.Servant.Server (shomeiRoutes)
import Shomei.Server.App (Env (..), runAppIO)
import Shomei.Server.Config (ProxyProtocolMode (..), ServerSettings (..), SweepSettings (..), loadConfig, loadNotifierSecretsFromEnv, toSweepConfig)
import Shomei.Server.Keys (LoadedKeys (..), bootstrapKeys, loadKekFromEnv, reloadKeys)
import Shomei.Server.Middleware.BodyLimit (bodyLimitMiddleware, defaultBodyLimitBytes)
import Shomei.Server.Middleware.RateLimit (RateLimiter, newRateLimiterFor, rateLimitMiddleware)
import Shomei.Server.Middleware.TrustedProxy (TrustedProxies, trustedProxyMiddleware, trustedProxyTexts)
import Shomei.Server.Observability.Logging (requestLoggingMiddleware)
import Shomei.Server.Observability.Metrics (Metrics, metricsEndpointMiddleware, metricsMiddleware, newMetrics)
import Shomei.Server.Runtime (hostWarpSettings)
import Shomei.Server.Supervisor (logJsonLine, supervisedLoop, supervisedLoopMicros)
import Shomei.Session.LoginAttempt.Domain (AccountKey (..))
import Shomei.Time.Postgres (runClockIO)
import System.Environment (lookupEnv)
import System.Exit (exitFailure)
import System.IO (BufferMode (LineBuffering), hPutStrLn, hSetBuffering, stderr, stdout)
import System.Posix.Signals (installHandler, sigHUP, sigINT, sigTERM)
import System.Posix.Signals qualified as Signals
-- | The full turnkey startup sequence.
main :: IO ()
main = do
-- Line-buffer stdout so each structured JSON log line is flushed immediately (when stdout
-- is a pipe/file it would otherwise be block-buffered and logs would not appear promptly).
hSetBuffering stdout LineBuffering
hSetBuffering stderr LineBuffering
(cfg, settings) <- loadConfig
let trustedProxySummary =
case trustedProxyTexts settings.serverTrustedProxies of
[] -> "none"
entries -> Text.intercalate "," entries
proxyProtocolSummary = case settings.serverProxyProtocol of
ProxyProtocolOff -> "none"
ProxyProtocolRequired -> "required"
hPutStrLn
stderr
( "[shomei] trusted proxies: "
<> Text.unpack trustedProxySummary
<> "; proxy protocol: "
<> proxyProtocolSummary
)
traverse_
(\warning -> hPutStrLn stderr ("[shomei] WARNING: " <> Text.unpack warning))
settings.serverWarnings
-- Warn, never refuse: a resource-starved dev box legitimately wants cheap hashing, but an
-- operator must not weaken password storage without seeing it said out loud.
traverse_
(\w -> hPutStrLn stderr ("[shomei] WARNING: " <> Text.unpack w))
(argon2WarningFloor settings.serverArgon2)
trialArgon2Derivation settings.serverArgon2 >>= \case
Right () -> pure ()
Left (Argon2Failure why) -> do
hPutStrLn
stderr
( "[shomei] FATAL: the Argon2 implementation rejected the configured parameters (m="
<> show settings.serverArgon2.memoryKiB
<> "KiB,t="
<> show settings.serverArgon2.iterations
<> ",p="
<> show settings.serverArgon2.parallelism
<> "): "
<> Text.unpack why
)
exitFailure
validateOidcIssuer cfg
env <- buildEnv cfg settings
(liveness, readiness) <- buildHealthChecks env
backgroundTasks <- installHostBackgroundTasks cfg settings env
rl <- newRateLimiterFor shomeiThrottledRoutes cfg.rateLimitConfig
metrics <- newMetrics
let obs = cfg.observabilityConfig
-- Graceful shutdown: SIGTERM (orchestrator stop) and SIGINT (Ctrl-C) trigger warp's
-- shutdown action, which stops accepting new connections and waits up to the
-- configured timeout for in-flight requests to finish. After warp returns we close the
-- pool and exit 0.
installShutdown closeSocket = do
let stop sig = hPutStrLn stderr ("[shomei] received " <> sig <> "; draining in-flight requests") >> closeSocket
_ <- installHandler sigTERM (Signals.Catch (stop "SIGTERM")) Nothing
_ <- installHandler sigINT (Signals.Catch (stop "SIGINT")) Nothing
pure ()
warpSettings <- hostWarpSettings cfg settings (Warp.setInstallShutdownHandler installShutdown Warp.defaultSettings)
hPutStrLn stderr ("[shomei] listening on :" <> show settings.serverPort)
Warp.runSettings warpSettings (hostMiddleware cfg settings rl metrics (application env liveness readiness))
stopHostBackgroundTasks backgroundTasks obs.gracefulShutdownTimeoutSeconds
hPutStrLn stderr "[shomei] drain complete; closing connection pools"
Pool.release env.envPool
hPutStrLn stderr "[shomei] shutdown complete"
-- | Background services required to give an embedded host the standalone server's runtime
-- guarantees. Call 'stopHostBackgroundTasks' after the host server stops accepting requests;
-- it drains queued notifications within the same bounded shutdown budget and releases the
-- sweeper's isolated pool.
newtype HostBackgroundTasks = HostBackgroundTasks
{ stopHostBackgroundTasks :: Int -> IO ()
}
-- | Install every process-wide and background service used by the standalone server: validate
-- configured default roles, refresh signing keys periodically and on @SIGHUP@, sweep expired
-- state, and deliver queued notifications. An embedding host calls this once after 'buildEnv'.
installHostBackgroundTasks :: ShomeiConfig -> ServerSettings -> Env -> IO HostBackgroundTasks
installHostBackgroundTasks cfg settings env = do
validateDefaultRoles cfg env
installKeyReload cfg env
releaseSweeper <- installSweeper settings env
notifierWorker <- installNotifierWorker env
pure
HostBackgroundTasks
{ stopHostBackgroundTasks = \timeoutSeconds -> do
drainNotifierWorker notifierWorker timeoutSeconds
releaseSweeper
}
-- | The WAI edge used by the standalone server. Wrap the host's whole application so its own
-- routes receive the same trusted-proxy attribution, request IDs, metrics, body cap, and
-- per-client rate limiting as Shōmei's routes.
hostMiddleware :: ShomeiConfig -> ServerSettings -> RateLimiter -> Metrics -> Middleware
hostMiddleware cfg settings =
edgeMiddleware cfg.observabilityConfig settings.serverTrustedProxies
-- | The WAI edge every host must install. The trusted-proxy rewrite is outermost so logging,
-- metrics, the limiter, and Servant's 'RemoteHost' all observe the same client address.
edgeMiddleware :: ObservabilityConfig -> TrustedProxies -> RateLimiter -> Metrics -> Middleware
edgeMiddleware obs proxies limiter metrics =
trustedProxyMiddleware proxies
. requestLoggingMiddleware obs
. withMetrics
. bodyLimitMiddleware defaultBodyLimitBytes
. rateLimitMiddleware limiter
where
withMetrics
| obs.metricsEnabled = metricsMiddleware metrics . metricsEndpointMiddleware metrics
| otherwise = id
-- | Refuse to start with the OIDC provider enabled and an issuer that is not an absolute
-- @http(s)@ URL.
--
-- The issuer doubles as the provider's public base URL: every endpoint in the discovery document
-- is derived from it, and ID tokens carry it as @iss@. With the default issuer @"shomei"@ the
-- document would advertise @shomei\/oauth\/token@ — a relative URL no client can fetch — and the
-- failure would surface as an inscrutable error inside someone else's OIDC library. Checked
-- before the pool is acquired, because it needs nothing but config.
validateOidcIssuer :: ShomeiConfig -> IO ()
validateOidcIssuer cfg =
when (cfg.oauthConfig.oidcEnabled && not (isAbsoluteHttpUrl iss)) do
hPutStrLn
stderr
( "shomei-server: oidcEnabled is set but issuer is not an absolute http(s) URL: "
<> show iss
<> "\nThe OIDC issuer is also the base URL every published endpoint is derived from."
<> "\nSet SHOMEI_ISSUER (or `issuer` in the config file) to this deployment's public"
<> " base URL, e.g. https://auth.example.com"
)
exitFailure
where
iss = case cfg.issuer of Issuer t -> t
-- | Refuse to start when @defaultRoles@ names a role missing from the @shomei_roles@ registry.
--
-- Validating once here rather than on every signup keeps the hot path free of catalog reads and
-- turns a config typo into an immediate, obvious startup failure instead of a stream of 500s on
-- an endpoint nobody is watching. The registry is append-only, so a role validated here cannot
-- later disappear — which is why 'Shomei.Authorization.Role.Workflow.applyDefaultRoles' does not re-check.
--
-- An embedding host that sets @defaultRoles@ should call
-- 'Shomei.Authorization.Role.Workflow.undefinedDefaultRoles' the same way where it assembles its ports.
validateDefaultRoles :: ShomeiConfig -> Env -> IO ()
validateDefaultRoles cfg env = do
outcome <-
runEff
. runErrorNoCallStack @AuthError
. runDatabasePoolWith env.envDbRetryPolicy env.envPool
. runRoleStorePostgres
$ undefinedDefaultRoles cfg
case outcome of
Left e -> die ("could not validate defaultRoles against the role registry: " <> show e)
Right missing
| Set.null missing -> pure ()
| otherwise -> do
let names = Text.intercalate ", " [r | Role r <- Set.toList missing]
first' = case Set.toList missing of
Role r : _ -> r
[] -> "<role>"
die
( "defaultRoles names undefined roles: "
<> Text.unpack names
<> "\ndefine them first: shomei-admin roles define "
<> Text.unpack first'
)
where
die msg = hPutStrLn stderr ("shomei-server: " <> msg) >> exitFailure
-- | Install the two triggers that refresh signing-key material on a running server, so
-- @shomei-admin keys activate@ / @keys revoke@ take effect with no restart: a periodic
-- background reload every @refreshIntervalSeconds@ (0 disables it), and a @SIGHUP@ handler
-- for a deterministic "apply now". Both call 'reloadKeys', which keeps the last good
-- material if a reload fails.
--
-- The periodic reload runs on 'supervisedLoop', the shared supervised-background-thread
-- idiom: a crash is logged and retried with backoff rather than killing the loop, and the
-- thread dies with the process. Note that 'supervisedLoop' runs its first cycle immediately,
-- so a reload happens right after 'bootstrapKeys' — harmless, since 'reloadKeys' is idempotent
-- and keeps the last good material on failure.
installKeyReload :: ShomeiConfig -> Env -> IO ()
installKeyReload cfg env = do
when (interval > 0) (void (forkIO (supervisedLoop "key-reload" interval reload)))
void (installHandler sigHUP (Signals.Catch onHup) Nothing)
where
interval = cfg.signingKeyConfig.refreshIntervalSeconds
reload = reloadKeys env.envKek env.envPool env.envKeys
onHup = hPutStrLn stderr "[shomei] SIGHUP: reloading signing keys" >> reload
-- | Fork the background expired-data sweeper, unless the operator disabled it in favor of
-- scheduling @shomei-admin sweep@ externally. Its one-connection pool is deliberately separate
-- from the request pool, so a long drain never consumes request capacity. The returned cleanup
-- releases that pool during graceful shutdown.
--
-- A 'Left' from 'sweepOnce' means the database was unreachable or a statement failed. That is
-- an ordinary outcome for periodic maintenance, not a crash: it is logged and the loop sleeps
-- a normal interval rather than entering 'supervisedLoop''s backoff, which is reserved for
-- genuine exceptions.
installSweeper :: ServerSettings -> Env -> IO (IO ())
installSweeper settings _env
| not sweep.sweepEnabled = pure (pure ())
| otherwise = do
sweepPool <-
acquirePool
1
(millisToDiffTime settings.serverDbPoolAcquisitionTimeoutMs)
settings.serverDbStatementTimeoutMs
settings.serverConnStr
hPutStrLn
stderr
( "[shomei] sweeper: every "
<> show sweep.sweepIntervalSeconds
<> "s, audit retention "
<> maybe "disabled (retain forever)" (\d -> show d <> " days") sweep.sweepAuthEventRetentionDays
<> ", on its own connection"
)
void (forkIO (supervisedLoop "sweeper" sweep.sweepIntervalSeconds (oneCycle sweepPool)))
pure (Pool.release sweepPool)
where
sweep = settings.serverSweep
oneCycle sweepPool = do
-- Wall-clock time decides which rows are expired; a monotonic clock measures how long
-- the sweep took, so an NTP step cannot produce a negative duration.
cutoffNow <- getCurrentTime
startTick <- getMonotonicTimeNSec
result <- sweepOnce sweepPool (toSweepConfig sweep) cutoffNow
endTick <- getMonotonicTimeNSec
logSweepCycle (durationMs startTick endTick) result
durationMs startTick endTick =
fromIntegral (endTick - startTick) / 1_000_000 :: Double
logSweepCycle elapsed = \case
Left err ->
logJsonLine
[ "level" .= ("error" :: Text),
"msg" .= ("sweep failed" :: Text),
"task" .= ("sweeper" :: Text),
"error" .= Text.pack (show err),
"duration_ms" .= elapsed
]
Right report ->
logJsonLine
( [ "level" .= ("info" :: Text),
"msg" .= ("sweep" :: Text)
]
<> [Key.fromText table .= deleted | (table, deleted) <- sweepReportCounts report]
<> ["duration_ms" .= elapsed]
)
-- | Handle returned by 'installNotifierWorker'. EP-9's embedding assembly lifts the install
-- and drain calls verbatim so an embedding host receives the same delivery guarantees.
newtype NotifierWorker = NotifierWorker
{ drainNotifierWorker :: Int -> IO ()
}
-- | Run one supervised delivery worker. Closing prevents new enqueue operations, then drain waits
-- within the server's graceful-shutdown budget before cancelling any remaining network call.
installNotifierWorker :: Env -> IO NotifierWorker
installNotifierWorker env = do
workerThread <-
forkIO
( supervisedLoopMicros
"notifier"
0
(5 * 1_000_000)
(300 * 1_000_000)
oneCycle
)
pure
NotifierWorker
{ drainNotifierWorker = \timeoutSeconds -> do
closeNotifierQueue env.envNotifierQueue
dropped <- drainNotifierQueue env.envNotifierQueue timeoutSeconds
logJsonLine
[ "level" .= (if dropped == 0 then "info" else "warn" :: Text),
"msg" .= ("notifier drained" :: Text),
"dropped" .= dropped
]
killThread workerThread
}
where
oneCycle =
withDequeued env.envNotifierQueue \notification -> do
result <-
runEff
. runErrorNoCallStack @AuthError
. runDatabasePoolWith env.envDbRetryPolicy env.envPool
. runClockIO
. runAuthEventPublisherPostgres
$ deliverNotification env.envHttpManager env.envNotifierSecrets env.envConfig notification
case result of
Right () -> pure ()
Left err ->
logJsonLine
[ "level" .= ("error" :: Text),
"msg" .= ("notifier delivery audit failed" :: Text),
"task" .= ("notifier" :: Text),
"error" .= Text.pack (show err)
]
-- | Run the schema migrations (idempotent), acquire the pool, and bootstrap the signing
-- key, yielding the assembled 'Env'. Shared by 'main' and by host applications that embed
-- the Shōmei API (the embedded demo builds its own 'Env' this way). Running
-- @shomei-migrate@ out-of-band is also supported for production.
buildEnv :: ShomeiConfig -> ServerSettings -> IO Env
buildEnv cfg settings = do
alg <-
either
(\why -> hPutStrLn stderr ("[shomei] FATAL: signingKeyConfig.algorithm: " <> Text.unpack why) >> exitFailure)
pure
(configSigningAlgorithm cfg)
applyShomeiMigrations settings.serverConnStr >>= \case
Left migrationError -> do
hPutStrLn stderr ("[shomei] FATAL: schema migration failed: " <> show migrationError)
exitFailure
Right report ->
hPutStrLn
stderr
("[shomei] schema migrations applied: " <> show (NonEmpty.length report.results) <> " migrations")
hPutStrLn
stderr
( "[shomei] db pool: size "
<> show settings.serverDbPoolSize
<> ", acquisition timeout "
<> show settings.serverDbPoolAcquisitionTimeoutMs
<> "ms, statement timeout "
<> show settings.serverDbStatementTimeoutMs
<> "ms"
)
pool <-
acquirePool
settings.serverDbPoolSize
(millisToDiffTime settings.serverDbPoolAcquisitionTimeoutMs)
settings.serverDbStatementTimeoutMs
settings.serverConnStr
checkTotpEnvelopeVersions pool >>= \case
Right (0, 0) -> pure ()
_ -> do
Pool.release pool
hPutStrLn stderr "[shomei] FATAL: TOTP envelope conversion required; stop writers and run shomei-admin totp migrate-envelopes --check, then --offline"
exitFailure
kek <- loadKekFromEnv
totpKey <- loadTotpKeyFromEnv cfg
notifierSecrets <- loadNotifierSecretsFromEnv cfg
keys <- bootstrapKeys kek alg pool
keysRef <- newIORef keys
mgr <- newTlsManager
limiter <- newHashingLimiter settings.serverHashingMaxConcurrency
notifierQueue <- newNotifierQueue settings.serverNotifierQueueSize
hPutStrLn
stderr
( "[shomei] hashing concurrency "
<> show (hashingLimit limiter)
<> ", argon2 m="
<> show settings.serverArgon2.memoryKiB
<> "KiB,t="
<> show settings.serverArgon2.iterations
<> ",p="
<> show settings.serverArgon2.parallelism
)
pure
Env
{ envPool = pool,
envDbRetryPolicy = TransactionRetryPolicy settings.serverDbTransactionMaxAttempts settings.serverDbTransactionDeadlineMs,
envConfig = cfg,
envKeys = keysRef,
envKek = kek,
envHttpManager = mgr,
envNotifierSecrets = notifierSecrets,
envNotifierQueue = notifierQueue,
envArgon2Params = settings.serverArgon2,
envHashingLimiter = limiter,
envTotpKey = totpKey
}
-- | The AES-256-GCM key that encrypts stored TOTP secrets (EP-7), loaded from
-- @SHOMEI_TOTP_ENCRYPTION_KEY@ (base64 of exactly 32 bytes; generate one with
-- @openssl rand -base64 32@).
--
-- When TOTP is enabled and the variable is absent or malformed, the server refuses to boot with
-- a loud message — an enabled factor whose secrets cannot be encrypted is a silent data-loss
-- trap. When TOTP is disabled the store is unreachable (enrollment is refused), so a valid key is
-- optional; a dummy all-zero key keeps the interpreter-stack shape fixed. A key supplied while
-- disabled is still validated, so a typo is caught before it is switched on.
loadTotpKeyFromEnv :: ShomeiConfig -> IO TotpEncryptionKey
loadTotpKeyFromEnv cfg = do
raw <- lookupEnv "SHOMEI_TOTP_ENCRYPTION_KEY"
let enabled = cfg.totpConfig.totpEnabled
case fmap Text.pack raw of
Just t | not (Text.null (Text.strip t)) ->
case totpEncryptionKeyFromBase64 t of
Right k -> pure k
Left err -> die ("SHOMEI_TOTP_ENCRYPTION_KEY " <> Text.unpack err)
_
| enabled -> die "SHOMEI_TOTP_ENCRYPTION_KEY is required when totpEnabled is set (generate one with: openssl rand -base64 32)"
| otherwise -> either (die . Text.unpack) pure (totpEncryptionKeyFromBytes (BS.replicate 32 0))
where
die msg = hPutStrLn stderr ("[shomei] " <> msg) >> exitFailure
-- | Milliseconds to a 'DiffTime', exactly (a 'DiffTime' counts picoseconds, so this loses
-- nothing). Used for the pool's acquisition timeout, which config carries as an integer count
-- of milliseconds.
millisToDiffTime :: Int -> DiffTime
millisToDiffTime ms = picosecondsToDiffTime (fromIntegral ms * 1_000_000_000)
-- | Build the WAI 'Application': EP-5's server with the @AuthProtect "shomei-jwt"@
-- 'Context'. Authentication is derived from the seam env's port runner and config, so it uses
-- the current verification keys and honors @sessionCheckMode@.
--
-- The served tree is 'shomeiRoutesApi' (EP-3): application routes under @\/v1@, JWKS and the
-- probes at unversioned root paths.
--
-- 'problemMiddleware' converts Servant's one un-formattable failure — the bare @405@ a method
-- mismatch raises below any 'ErrorFormatters' hook — into a problem document, so /every/ error
-- the process emits carries the same envelope.
application :: Env -> ProbeCheck -> ProbeCheck -> Application
application env liveness readiness =
problemMiddleware (serveWithContext shomeiRoutesApi (authContext senv) (shomeiRoutes senv liveness readiness))
where
senv = seamEnv env
-- | The Servant 'Context' carrying the @AuthProtect "shomei-jwt"@ 'AuthHandler' (built from the
-- seam env itself) and the 'ErrorFormatters' that render Servant's own body-parse,
-- url-parse, header-parse, and not-found failures as RFC 9457 problem documents. A host app
-- embedding 'ShomeiAPI' serves with this same context.
authContext :: Seam.Env -> Context '[AuthHandler Request AuthUser, ErrorFormatters]
authContext senv =
authHandler senv
:. shomeiErrorFormatters
:. EmptyContext
-- | Build EP-5's seam 'Seam.Env' from this server's assembly 'Env'. The port runner
-- bridges EP-5's smaller stack onto the PostgreSQL stack with @inject@ while preserving
-- its typed error channel.
seamEnv :: Env -> Seam.Env
seamEnv env =
Seam.Env
{ Seam.runPorts = runPorts,
Seam.config = env.envConfig,
-- The JWKS getter reads swappable key material, so a rotation applied by 'reloadKeys'
-- takes effect on the next request without rebuilding the WAI application. Token
-- verification now goes through 'Seam.runPorts'; 'runAppIO' also re-reads these keys.
Seam.jwksJson = (.jwksBody) <$> readIORef env.envKeys,
Seam.accountKeyOf = AccountKey . sha256Hex
}
where
runPorts :: forall a. Eff Seam.AppEffects a -> IO (Either AuthError a)
runPorts act = runAppIO env (inject act)