packages feed

shomei-servant-0.2.0.0: src/Shomei/Servant/Error.hs

-- | The single error vocabulary of the HTTP surface, and the one function that renders it.
--
-- Every failure Shōmei returns — from a workflow, from the auth handler, from an authorization
-- combinator, from Servant's own request parser, from the rate-limit middleware — is an
-- __RFC 9457 problem document__ served as @application/problem+json@:
--
-- @
-- {"type":"https://github.com/shinzui/shomei/blob/master/docs/user/problem-details.md#token_invalid",
--  "title":"Token is invalid","status":401,"code":"token_invalid","retryable":false}
-- @
--
-- @type@ is the stable, dereferenceable primary identifier. @title@ is stable human text,
-- @status@ mirrors the HTTP status, and @code@ and @retryable@ are Shōmei extensions. Optional
-- @detail@ and @instance@ members describe a safe occurrence.
--
-- 'ProblemSpec' constants are the single source shared by the runtime mapping here and by the
-- OpenAPI error documentation in "Shomei.Servant.OpenApi", so a status or title cannot drift
-- between what the server sends and what the spec promises.
--
-- Two deliberate exemptions:
--
--   * health probe failures use the structured 'Servant.Health.ProbeResult' body,
--     not a problem document. It is a status report, not an error.
--   * The future @POST \/oauth\/token@ endpoint must use RFC 6749 §5.2's
--     @{"error":"invalid_grant",…}@ shape, which OAuth2 clients require. That surface belongs
--     to MasterPlan 7 EP-4 and is exempt from this envelope.
--
-- Never leaks internal detail: 'InvalidCredentials', 'UserNotActive', and 'AccountLocked' all
-- collapse to the same generic @401 invalid_login@ so account existence and status are not
-- disclosed, and 'InternalAuthError' carries no detail to the client.
module Shomei.Servant.Error
  ( -- * The envelope
    ProblemDetails (..),
    ProblemJSON,
    ProblemSpec (..),
    ProblemOccurrence (..),
    noProblemOccurrence,
    detailOccurrence,
    bearerOccurrence,
    retryAfterOccurrence,
    problemTypeFor,
    problemDetails,
    toProblemError,
    problemBody,
    problemHeaders,

    -- * The catalog
    problemCatalog,
    authErrorProblem,
    authErrorToServerError,

    -- * Servant's built-in failures
    shomeiErrorFormatters,

    -- * Specs with an 'AuthError' counterpart

    --
    -- Exported in full so "Shomei.Servant.OpenApi" can name them in its route→codes
    -- table: the spec's documented status and title are then literally the ones the
    -- server sends.
    pcInvalidEmail,
    pcInvalidLoginId,
    pcWeakPassword,
    pcEmailTaken,
    pcLoginIdTaken,
    pcInvalidLogin,
    pcTooManyRequests,
    pcSessionNotFound,
    pcSessionExpired,
    pcSessionRevoked,
    pcRefreshTokenInvalid,
    pcRefreshTokenExpired,
    pcTokenReuse,
    pcVerificationTokenInvalid,
    pcPasswordResetTokenInvalid,
    pcEmailAlreadyVerified,
    pcEmailNotVerified,
    pcTokenInvalid,
    pcPasskeyNotFound,
    pcCeremonyNotFound,
    pcWebAuthnFailed,
    pcMfaFailed,
    pcTotpDisabled,
    pcTotpAlreadyEnrolled,
    pcTotpEnrollmentNotFound,
    pcTotpCodeInvalid,
    pcRecoveryCodeInvalid,
    pcReauthenticationRequired,
    pcImpersonationForbidden,
    pcImpersonationTargetInvalid,
    pcImpersonationActionBlocked,
    pcUserNotFound,
    pcRoleNotDefined,
    pcInvalidUserStatus,
    pcUserHasNoEmail,
    pcDependencyUnavailable,
    pcInternal,

    -- * HTTP-layer specs (no 'AuthError' counterpart)
    pcMissingToken,
    pcTokenInvalidAuth,
    pcMissingRole,
    pcMissingScope,
    pcMissingPermission,
    pcCsrfRejected,
    pcBadRequest,
    pcPayloadTooLarge,
    pcBodyParseError,
    pcNotFound,
    pcMethodNotAllowed,
    pcSelfTargetForbidden,
    pcRoleNotGranted,

    -- * Statuses Servant does not ship
    err422,
    err429,
  )
where

import Control.Lens
import Data.Aeson (eitherDecode)
import Data.Aeson qualified as Aeson
import Data.HashMap.Strict.InsOrd.Compat qualified as IOHM
import Data.OpenApi (NamedSchema (..), ToSchema (..))
import Data.OpenApi qualified as O
import Data.Text qualified as Text
import Data.Text.Encoding qualified as TextEncoding
import Network.HTTP.Media (MediaType)
import Network.HTTP.Types.Header (Header)
import Numeric.Natural (Natural)
import Servant
  ( Accept (..),
    ErrorFormatters (..),
    MimeRender (..),
    MimeUnrender (..),
    ServerError (..),
    defaultErrorFormatters,
    err400,
    err401,
    err403,
    err404,
    err405,
    err409,
    err413,
    err500,
    err503,
  )
import Shomei.Authorization.Claims.Domain (Role (..))
import Shomei.Error (AuthError (..))
import Shomei.Prelude

-- ---------------------------------------------------------------------------
-- Statuses Servant does not ship
-- ---------------------------------------------------------------------------

-- | HTTP 429 Too Many Requests.
err429 :: ServerError
err429 =
  ServerError
    { errHTTPCode = 429,
      errReasonPhrase = "Too Many Requests",
      errBody = "",
      errHeaders = []
    }

-- | HTTP 422 Unprocessable Content.
err422 :: ServerError
err422 =
  ServerError
    { errHTTPCode = 422,
      errReasonPhrase = "Unprocessable Content",
      errBody = "",
      errHeaders = []
    }

-- ---------------------------------------------------------------------------
-- The envelope
-- ---------------------------------------------------------------------------

-- | RFC 9457 body plus Shōmei's stable extension members.
data ProblemDetails = ProblemDetails
  { problemType :: !Text,
    title :: !Text,
    status :: !Int,
    detail :: !(Maybe Text),
    problemInstance :: !(Maybe Text),
    code :: !Text,
    retryable :: !Bool
  }
  deriving stock (Eq, Show, Generic)

problemJsonOptions :: Options
problemJsonOptions =
  defaultOptions
    { fieldLabelModifier = \case
        "problemType" -> "type"
        "problemInstance" -> "instance"
        field -> field,
      omitNothingFields = True
    }

instance ToJSON ProblemDetails where
  toJSON = genericToJSON problemJsonOptions

instance FromJSON ProblemDetails where
  parseJSON = genericParseJSON problemJsonOptions

-- | The fixed media type for application errors.
data ProblemJSON

instance Accept ProblemJSON where
  contentType _ = "application/problem+json" :: MediaType

instance MimeRender ProblemJSON ProblemDetails where
  mimeRender _ = Aeson.encode

instance MimeUnrender ProblemJSON ProblemDetails where
  mimeUnrender _ = eitherDecode

instance ToSchema ProblemDetails where
  declareNamedSchema _ = pure (NamedSchema (Just "ProblemDetails") problemDetailsSchema)

problemDetailsSchema :: O.Schema
problemDetailsSchema =
  mempty
    & O.type_ ?~ O.OpenApiTypeSingle O.OpenApiObject
    & O.description ?~ "RFC 9457 Problem Details with Shomei code and retryability extensions."
    & O.properties
      .~ IOHM.fromList
        [ ("type", O.Inline (stringSchema & O.format ?~ "uri-reference")),
          ("title", O.Inline stringSchema),
          ("status", O.Inline (integerSchema & O.minimum_ ?~ 100 & O.maximum_ ?~ 599)),
          ("detail", O.Inline stringSchema),
          ("instance", O.Inline (stringSchema & O.format ?~ "uri-reference")),
          ("code", O.Inline stringSchema),
          ("retryable", O.Inline (mempty & O.type_ ?~ O.OpenApiTypeSingle O.OpenApiBoolean))
        ]
    & O.required .~ ["type", "title", "status", "code", "retryable"]
    & O.additionalProperties ?~ O.AdditionalPropertiesAllowed True

stringSchema :: O.Schema
stringSchema = mempty & O.type_ ?~ O.OpenApiTypeSingle O.OpenApiString

integerSchema :: O.Schema
integerSchema = mempty & O.type_ ?~ O.OpenApiTypeSingle O.OpenApiInteger

-- | One stable error kind shared by handler results and pre-handler rendering.
--
-- These constants are the SINGLE SOURCE shared by 'authErrorToServerError' below and by the
-- OpenAPI error documentation, so the two cannot disagree about a status or a title.
data ProblemSpec = ProblemSpec
  { problemCode :: !Text,
    -- | the Servant base error; only its status and reason phrase are used
    problemStatus :: !ServerError,
    problemTitle :: !Text,
    problemRetryable :: !Bool
  }

problemSpec :: Text -> ServerError -> Text -> ProblemSpec
problemSpec problemCode problemStatus problemTitle =
  ProblemSpec {problemCode, problemStatus, problemTitle, problemRetryable = False}

retryableProblemSpec :: Text -> ServerError -> Text -> ProblemSpec
retryableProblemSpec problemCode problemStatus problemTitle =
  ProblemSpec {problemCode, problemStatus, problemTitle, problemRetryable = True}

-- | Safe occurrence-specific data and optional response headers.
data ProblemOccurrence = ProblemOccurrence
  { occurrenceDetail :: !(Maybe Text),
    instanceUri :: !(Maybe Text),
    wwwAuthenticate :: !(Maybe Text),
    retryAfterSeconds :: !(Maybe Natural)
  }
  deriving stock (Eq, Show, Generic)

noProblemOccurrence :: ProblemOccurrence
noProblemOccurrence = ProblemOccurrence Nothing Nothing Nothing Nothing

detailOccurrence :: Text -> ProblemOccurrence
detailOccurrence value = noProblemOccurrence {occurrenceDetail = Just value}

bearerOccurrence :: ProblemOccurrence
bearerOccurrence = noProblemOccurrence {wwwAuthenticate = Just "Bearer"}

retryAfterOccurrence :: Natural -> ProblemOccurrence
retryAfterOccurrence seconds = noProblemOccurrence {retryAfterSeconds = Just seconds}

problemTypeFor :: Text -> Text
problemTypeFor problemCode =
  "https://github.com/shinzui/shomei/blob/master/docs/user/problem-details.md#" <> problemCode

problemDetails :: ProblemSpec -> ProblemOccurrence -> ProblemDetails
problemDetails spec occurrence =
  ProblemDetails
    { problemType = problemTypeFor spec.problemCode,
      title = spec.problemTitle,
      status = spec.problemStatus.errHTTPCode,
      detail = occurrence.occurrenceDetail,
      problemInstance = occurrence.instanceUri,
      code = spec.problemCode,
      retryable = spec.problemRetryable
    }

problemBody :: ProblemSpec -> ProblemOccurrence -> Aeson.Value
problemBody spec = toJSON . problemDetails spec

-- | The response headers a problem document carries at a given status.
--
-- A 401 advertises the scheme the client should use (RFC 6750 §3); a 429 tells the client how
-- long to wait. The token bucket refills continuously, so 60 seconds is an honest upper bound
-- for a full per-minute budget rather than an exact wait.
problemHeaders :: ProblemOccurrence -> [Header]
problemHeaders occurrence =
  [("Content-Type", "application/problem+json")]
    <> foldMap (\value -> [("WWW-Authenticate", encodeUtf8 value)]) occurrence.wwwAuthenticate
    <> foldMap (\seconds -> [("Retry-After", showBytes seconds)]) occurrence.retryAfterSeconds
  where
    encodeUtf8 = TextEncoding.encodeUtf8
    showBytes = encodeUtf8 . Text.pack . show

-- | Render a spec as an RFC 9457 'ServerError'. 'Nothing' omits the @detail@ member.
toProblemError :: ProblemSpec -> ProblemOccurrence -> ServerError
toProblemError spec occurrence =
  spec.problemStatus
    { errBody = Aeson.encode (problemDetails spec occurrence),
      errHeaders = problemHeaders occurrence
    }

-- ---------------------------------------------------------------------------
-- Servant's own request-parsing failures
-- ---------------------------------------------------------------------------

-- | Replace Servant's plain-text 400/404 bodies with problem documents.
--
-- __Servant's 405 is not reachable from here.__ @ErrorFormatters@ has exactly four hooks —
-- body-parse, url-parse, header-parse, and not-found — while a method mismatch raises a
-- hardcoded @err405@ (empty body) inside @Servant.Server.Internal.methodCheck@. The
-- 'Shomei.Servant.Middleware.problemMiddleware' WAI layer converts that one.
shomeiErrorFormatters :: ErrorFormatters
shomeiErrorFormatters =
  defaultErrorFormatters
    { bodyParserErrorFormatter = \_typeRep _req msg ->
        toProblemError pcBodyParseError (detailOccurrence (Text.pack msg)),
      urlParseErrorFormatter = \_typeRep _req msg ->
        toProblemError pcBadRequest (detailOccurrence (Text.pack msg)),
      headerParseErrorFormatter = \_typeRep _req msg ->
        toProblemError pcBadRequest (detailOccurrence (Text.pack msg)),
      notFoundErrorFormatter = \_req -> toProblemError pcNotFound noProblemOccurrence
    }

-- ---------------------------------------------------------------------------
-- The catalog
-- ---------------------------------------------------------------------------

-- Specs with an 'AuthError' counterpart. The code/status/title triple is the stable public
-- identity used by both pre-handler rendering and typed handler results.

pcInvalidEmail, pcInvalidLoginId, pcWeakPassword :: ProblemSpec
pcInvalidEmail = problemSpec "invalid_email" err400 "Email is not valid"
pcInvalidLoginId = problemSpec "invalid_login_id" err400 "Login identifier is not valid"
pcWeakPassword = problemSpec "weak_password" err400 "Password does not meet policy"

pcEmailTaken, pcLoginIdTaken :: ProblemSpec
pcEmailTaken = problemSpec "email_taken" err409 "Email is already registered"
pcLoginIdTaken = problemSpec "login_id_taken" err409 "Login identifier is already registered"

-- | The single generic answer for a wrong password, an unknown account, and a locked account.
pcInvalidLogin :: ProblemSpec
pcInvalidLogin = problemSpec "invalid_login" err401 "Invalid login identifier or password"

pcTooManyRequests :: ProblemSpec
pcTooManyRequests = retryableProblemSpec "too_many_requests" err429 "Too many requests"

pcSessionNotFound, pcSessionExpired, pcSessionRevoked :: ProblemSpec
pcSessionNotFound = problemSpec "session_not_found" err404 "Session not found"
pcSessionExpired = problemSpec "session_expired" err401 "Session expired"
pcSessionRevoked = problemSpec "session_revoked" err401 "Session revoked"

pcRefreshTokenInvalid, pcRefreshTokenExpired, pcTokenReuse :: ProblemSpec
pcRefreshTokenInvalid = problemSpec "token_invalid" err401 "Token is invalid"
pcRefreshTokenExpired = problemSpec "token_expired" err401 "Refresh token expired"
pcTokenReuse = problemSpec "token_reuse" err401 "Refresh token reuse detected"

pcVerificationTokenInvalid, pcPasswordResetTokenInvalid, pcEmailAlreadyVerified :: ProblemSpec
pcVerificationTokenInvalid = problemSpec "verification_token_invalid" err400 "Verification token is invalid"
pcPasswordResetTokenInvalid = problemSpec "password_reset_token_invalid" err400 "Password reset token is invalid"
pcEmailAlreadyVerified = problemSpec "email_already_verified" err409 "Email is already verified"

-- | 403, not 401: the credential WAS correct; the account is simply not yet eligible.
pcEmailNotVerified :: ProblemSpec
pcEmailNotVerified = problemSpec "email_not_verified" err403 "Email address is not verified"

-- | The access token failed verification. Deliberately does not say why.
pcTokenInvalid :: ProblemSpec
pcTokenInvalid = problemSpec "token_invalid" err401 "Token is invalid"

pcPasskeyNotFound, pcCeremonyNotFound, pcWebAuthnFailed, pcMfaFailed :: ProblemSpec
pcPasskeyNotFound = problemSpec "passkey_not_found" err404 "Passkey not found"
pcCeremonyNotFound = problemSpec "ceremony_not_found" err404 "Registration ceremony not found or expired"
pcWebAuthnFailed = problemSpec "webauthn_verification_failed" err400 "Passkey registration could not be verified"
pcMfaFailed = problemSpec "mfa_failed" err401 "Multi-factor authentication failed"

-- | EP-7 TOTP / recovery-code failures. The invalid-code specs are 401s that deliberately do
-- not distinguish a wrong code from a replayed one from an absent credential.
pcTotpDisabled, pcTotpAlreadyEnrolled, pcTotpEnrollmentNotFound, pcTotpCodeInvalid, pcRecoveryCodeInvalid :: ProblemSpec
pcTotpDisabled = problemSpec "totp_disabled" err403 "TOTP is not enabled"
pcTotpAlreadyEnrolled = problemSpec "totp_already_enrolled" err409 "A TOTP credential is already enrolled"
pcTotpEnrollmentNotFound = problemSpec "totp_enrollment_not_found" err404 "No pending TOTP enrollment to verify"
pcTotpCodeInvalid = problemSpec "totp_code_invalid" err401 "TOTP code is invalid"
pcRecoveryCodeInvalid = problemSpec "recovery_code_invalid" err401 "Recovery code is invalid"

-- | EP-7: a sensitive self-service action (recovery-code regeneration) requires a recently issued
-- access token. Raised by the HTTP layer's freshness gate, not by an 'AuthError'.
pcReauthenticationRequired :: ProblemSpec
pcReauthenticationRequired = problemSpec "reauthentication_required" err403 "Recent authentication required for this action"

pcImpersonationForbidden, pcImpersonationTargetInvalid, pcImpersonationActionBlocked :: ProblemSpec
pcImpersonationForbidden = problemSpec "impersonation_forbidden" err403 "Not allowed to impersonate"
pcImpersonationTargetInvalid = problemSpec "impersonation_target_invalid" err400 "Invalid impersonation target"
pcImpersonationActionBlocked = problemSpec "impersonation_action_blocked" err403 "This action is not permitted while impersonating"

pcUserNotFound, pcRoleNotDefined, pcDependencyUnavailable, pcInternal :: ProblemSpec
pcUserNotFound = problemSpec "user_not_found" err404 "User not found"
pcRoleNotDefined = problemSpec "role_not_defined" err422 "Role not defined"
pcDependencyUnavailable = retryableProblemSpec "dependency_unavailable" err503 "Required dependency unavailable"
pcInternal = problemSpec "internal" err500 "Internal authentication error"

-- | EP-2's admin lifecycle. Both are 409s: the request was well-formed and authorized, but the
-- target's state refuses it.
pcInvalidUserStatus, pcUserHasNoEmail :: ProblemSpec
pcInvalidUserStatus = problemSpec "invalid_user_status" err409 "User is not in a state that allows this action"
pcUserHasNoEmail = problemSpec "user_has_no_email" err409 "User has no email address"

-- Specs raised by the HTTP layer, with no 'AuthError' counterpart.

-- | No credential was presented at all — distinct from one that failed verification.
pcMissingToken :: ProblemSpec
pcMissingToken = problemSpec "missing_token" err401 "Authentication required"

-- | The auth handler's invalid-token 401. Shares the @token_invalid@ code with 'pcTokenInvalid'
-- and, like it, deliberately does not distinguish expired from forged from malformed.
pcTokenInvalidAuth :: ProblemSpec
pcTokenInvalidAuth = problemSpec "token_invalid" err401 "Token is invalid"

pcMissingRole, pcMissingScope, pcMissingPermission, pcCsrfRejected :: ProblemSpec
pcMissingRole = problemSpec "missing_role" err403 "Missing required role"
pcMissingScope = problemSpec "missing_scope" err403 "Missing required scope"

-- | EP-9: the @RequirePermission@ combinator's 403 — the token's @permissions@ claim does not
-- contain the required capability. Distinct code from @missing_role@ so a client can tell a
-- role-gated route from a permission-gated one.
pcMissingPermission = problemSpec "missing_permission" err403 "Missing required permission"

pcCsrfRejected = problemSpec "csrf_rejected" err403 "Origin not allowed for cookie-authenticated request"

-- | A malformed or incomplete request the handler rejected; the @detail@ says what.
pcBadRequest :: ProblemSpec
pcBadRequest = problemSpec "bad_request" err400 "Bad request"

-- | The edge stopped reading a request after it crossed the configured body cap.
pcPayloadTooLarge :: ProblemSpec
pcPayloadTooLarge = problemSpec "payload_too_large" err413 "Request body too large"

-- | Servant could not parse the JSON request body; the @detail@ carries the parse message.
pcBodyParseError :: ProblemSpec
pcBodyParseError = problemSpec "body_parse_error" err400 "Request body could not be parsed"

pcNotFound, pcMethodNotAllowed :: ProblemSpec
pcNotFound = problemSpec "not_found" err404 "Resource not found"
pcMethodNotAllowed = problemSpec "method_not_allowed" err405 "Method not allowed"

-- | EP-2: an administrator tried to suspend or delete their own account. Refused so a single
-- mistyped request cannot lock the last administrator out of a deployment; the @shomei-admin@ CLI
-- on the box remains the escape hatch for genuinely removing one.
pcSelfTargetForbidden :: ProblemSpec
pcSelfTargetForbidden = problemSpec "self_target_forbidden" err403 "An administrator cannot perform this action on their own account"

-- | EP-2: a role revocation named a role the user did not hold. A @404@ rather than a silent
-- success, so a typo in the role name is visible.
pcRoleNotGranted :: ProblemSpec
pcRoleNotGranted = problemSpec "role_not_granted" err404 "User does not hold that role"

-- | Every problem kind Shōmei can emit. The OpenAPI documentation is generated from this list,
-- and a conformance test asserts every documented code appears here.
--
-- Note that @token_invalid@ appears three times (an invalid access token, an invalid refresh
-- token, and the auth handler's rejection): the code is what clients switch on, and those three
-- are the same condition to a client. The titles differ because the causes do.
problemCatalog :: [ProblemSpec]
problemCatalog =
  [ pcInvalidEmail,
    pcInvalidLoginId,
    pcWeakPassword,
    pcEmailTaken,
    pcLoginIdTaken,
    pcInvalidLogin,
    pcTooManyRequests,
    pcSessionNotFound,
    pcSessionExpired,
    pcSessionRevoked,
    pcRefreshTokenInvalid,
    pcRefreshTokenExpired,
    pcTokenReuse,
    pcVerificationTokenInvalid,
    pcPasswordResetTokenInvalid,
    pcEmailAlreadyVerified,
    pcEmailNotVerified,
    pcTokenInvalid,
    pcPasskeyNotFound,
    pcCeremonyNotFound,
    pcWebAuthnFailed,
    pcMfaFailed,
    pcTotpDisabled,
    pcTotpAlreadyEnrolled,
    pcTotpEnrollmentNotFound,
    pcTotpCodeInvalid,
    pcRecoveryCodeInvalid,
    pcReauthenticationRequired,
    pcImpersonationForbidden,
    pcImpersonationTargetInvalid,
    pcImpersonationActionBlocked,
    pcUserNotFound,
    pcRoleNotDefined,
    pcInvalidUserStatus,
    pcUserHasNoEmail,
    pcDependencyUnavailable,
    pcInternal,
    pcMissingToken,
    pcTokenInvalidAuth,
    pcMissingRole,
    pcMissingScope,
    pcMissingPermission,
    pcCsrfRejected,
    pcBadRequest,
    pcPayloadTooLarge,
    pcBodyParseError,
    pcNotFound,
    pcMethodNotAllowed,
    pcSelfTargetForbidden,
    pcRoleNotGranted
  ]

-- | The one total mapping from a domain error to its application problem and occurrence.
-- Returned handler results and thrown pre-handler errors deliberately share this function.
authErrorProblem :: AuthError -> (ProblemSpec, ProblemOccurrence)
authErrorProblem = \case
  InvalidEmail -> plain pcInvalidEmail
  InvalidLoginId -> plain pcInvalidLoginId
  WeakPassword _ -> plain pcWeakPassword
  EmailAlreadyRegistered -> plain pcEmailTaken
  LoginIdAlreadyRegistered -> plain pcLoginIdTaken
  InvalidCredentials -> plain pcInvalidLogin
  UserNotActive -> plain pcInvalidLogin
  AccountLocked -> plain pcInvalidLogin
  TooManyRequests -> (pcTooManyRequests, retryAfterOccurrence 60)
  SessionNotFound -> plain pcSessionNotFound
  SessionExpired -> plain pcSessionExpired
  SessionRevoked -> plain pcSessionRevoked
  RefreshTokenInvalid -> plain pcRefreshTokenInvalid
  RefreshTokenExpired -> plain pcRefreshTokenExpired
  RefreshTokenReuseDetected -> plain pcTokenReuse
  VerificationTokenInvalid -> plain pcVerificationTokenInvalid
  PasswordResetTokenInvalid -> plain pcPasswordResetTokenInvalid
  EmailAlreadyVerified -> plain pcEmailAlreadyVerified
  EmailNotVerified -> plain pcEmailNotVerified
  TokenInvalid _ -> plain pcTokenInvalid
  PasskeyNotFound -> plain pcPasskeyNotFound
  PendingCeremonyNotFound -> plain pcCeremonyNotFound
  WebAuthnCeremonyError _ -> plain pcWebAuthnFailed
  MfaAssertionInvalid -> plain pcMfaFailed
  TotpDisabled -> plain pcTotpDisabled
  TotpAlreadyEnrolled -> plain pcTotpAlreadyEnrolled
  TotpEnrollmentNotFound -> plain pcTotpEnrollmentNotFound
  TotpCodeInvalid -> plain pcTotpCodeInvalid
  RecoveryCodeInvalid -> plain pcRecoveryCodeInvalid
  ImpersonationForbidden -> plain pcImpersonationForbidden
  ImpersonationTargetInvalid -> plain pcImpersonationTargetInvalid
  ImpersonationActionBlocked -> plain pcImpersonationActionBlocked
  -- EP-4's two OAuth errors are raised only by 'Shomei.ServiceAccount.ClientCredentials.Workflow', whose sole
  -- caller is @POST \/oauth\/token@ — and that handler renders them through
  -- 'Shomei.Servant.OAuth.oauthError' in the RFC 6749 §5.2 shape, never through this function
  -- (see the exemption in this module's header). These two arms exist so the @\case@ stays total,
  -- and map to generic application errors only to keep this conversion total.
  OAuthClientInvalid -> plain pcInvalidLogin
  OAuthScopeInvalid -> plain pcBadRequest
  -- EP-6's two token-exchange errors are, like EP-4's above, raised only by the
  -- @POST \/oauth\/token@ dispatcher (via 'Shomei.OAuth.TokenExchange.Workflow'), which renders them in
  -- the RFC 6749 §5.2 shape, never through this function. These arms keep the @\case@ total and
  -- reuse existing catalog specs (400s) rather than minting codes no route can emit.
  OAuthGrantInvalid -> plain pcBadRequest
  OAuthRequestMalformed -> plain pcBadRequest
  UserNotFound -> plain pcUserNotFound
  -- The offending name is request-specific, so it belongs in 'detail', keeping 'title' stable
  -- for the OpenAPI catalog.
  RoleNotDefined (Role r) -> (pcRoleNotDefined, detailOccurrence r)
  InvalidUserStatus -> plain pcInvalidUserStatus
  UserHasNoEmail -> plain pcUserHasNoEmail
  DependencyUnavailable _ -> plain pcDependencyUnavailable
  InternalAuthError _ -> plain pcInternal
  where
    plain spec = (spec, noProblemOccurrence)

-- | Render a domain error at a pre-handler boundary.
authErrorToServerError :: AuthError -> ServerError
authErrorToServerError err =
  let (spec, occurrence) = authErrorProblem err
   in toProblemError spec occurrence