packages feed

shomei-core-0.2.0.0: src/Shomei/Session/UnitOfWork/Store.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE TypeFamilies #-}

-- | The transactional unit-of-work port: the multi-table write tails that must be atomic.
--
-- Every other store port in this package is one effect per table, and each of its operations
-- is one SQL statement in its own database round-trip. That is the right shape for reads and
-- for standalone writes, but it is wrong for the write /tails/ of the authentication
-- workflows, where several inserts must either all land or none of them do. Persisting a
-- session but not its refresh token leaves a row nothing can ever use; marking a refresh token
-- used but failing to insert its replacement logs the user out mid-rotation.
--
-- This port names those tails as single operations. The PostgreSQL interpreter
-- (@Shomei.Session.UnitOfWork.Postgres@) runs each one inside a single @BEGIN … COMMIT@; the
-- in-memory interpreter ('Shomei.Test.InMemory') performs the equivalent update to its
-- mutable world in one atomic step. The per-table ports remain for reads, single-table writes,
-- and callers whose revocation scope or audit contract differs from these workflow tails.
module Shomei.Session.UnitOfWork.Store
  ( AuthUnitOfWork (..),
    NewSessionToken (..),
    RotationOutcome (..),
    persistNewSession,
    rotateRefreshToken,
    completePasswordReset,
    completePasswordChange,
    revokeSessionWithTokens,
  )
where

import Effectful (Dispatch (..), DispatchOf, Eff, Effect, (:>))
import Effectful.Dispatch.Dynamic (send)
import Shomei.Account.Password.Domain (PasswordHash)
import Shomei.Audit.Event.Domain (AuthEvent)
import Shomei.Id (PasswordResetTokenId, RefreshTokenId, SessionId, UserId)
import Shomei.Prelude
import Shomei.Session.Domain (NewSession, Session)
import Shomei.Session.RefreshToken.Domain (NewRefreshToken, PersistedRefreshToken, RefreshTokenHash)

-- | The refresh-token half of a brand-new session, minus the session id.
--
-- The session id is absent because the caller does not know it yet: it is generated inside the
-- interpreter, exactly as @CreateSession@ generates it today. The interpreter fills it in when
-- it builds the token row.
data NewSessionToken = NewSessionToken
  { tokenHash :: !RefreshTokenHash,
    createdAt :: !UTCTime,
    expiresAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)

-- | The result of an atomic refresh-token rotation.
--
-- 'RotationConflict' means the compare-and-swap that transitions the presented token
-- @active → used@ matched no row, i.e. some other request already spent it. That is
-- indistinguishable from a stolen token being replayed, so callers treat it as reuse. It is a
-- /signal/, not an error: the transaction simply did not rotate, and no replacement token was
-- inserted. Callers must never re-read the token to "confirm" this — the conflict is the
-- confirmation.
data RotationOutcome
  = Rotated !PersistedRefreshToken
  | RotationConflict
  deriving stock (Generic, Eq, Show)

data AuthUnitOfWork :: Effect where
  -- | Insert a session, its first refresh token, and the audit events built from the
  -- generated session id — atomically. Returns the persisted session and token.
  --
  -- The events arrive as a function of the session id rather than as a list because the id is
  -- generated inside the interpreter, yet the events must name it: signup publishes
  -- @UserRegistered@ + @SessionStarted@, login and MFA completion publish @LoginSucceeded@ +
  -- @SessionStarted@. The builder lets each caller author its own events in the workflow layer
  -- while the interpreter supplies the id.
  PersistNewSession ::
    NewSession ->
    NewSessionToken ->
    (SessionId -> [AuthEvent]) ->
    AuthUnitOfWork m (Session, PersistedRefreshToken)
  -- | Mark the presented refresh token used, insert its replacement, and record the rotation
  -- event — atomically. The 'UTCTime' is the @used_at@ stamp for the token being retired.
  --
  -- Yields 'RotationConflict' without inserting anything when the presented token was no
  -- longer active.
  RotateRefreshToken ::
    RefreshTokenId ->
    UTCTime ->
    NewRefreshToken ->
    AuthEvent ->
    AuthUnitOfWork m RotationOutcome
  -- | Consume the reset token (CAS); then update the hash, revoke every session and refresh
  -- token of the user, revoke the user's other outstanding reset tokens, and record the events
  -- atomically. 'False' means the CAS lost and nothing was written. The caller computes the hash.
  CompletePasswordReset ::
    PasswordResetTokenId ->
    UserId ->
    PasswordHash ->
    UTCTime ->
    [AuthEvent] ->
    AuthUnitOfWork m Bool
  -- | Update the hash, revoke every session and refresh token, and record the events atomically.
  CompletePasswordChange ::
    UserId ->
    PasswordHash ->
    UTCTime ->
    [AuthEvent] ->
    AuthUnitOfWork m ()
  -- | CAS the session @active → revoked@; only on success revoke its refresh tokens and record
  -- the events. 'False' means the session was already dead and nothing was written.
  RevokeSessionWithTokens ::
    SessionId ->
    UTCTime ->
    [AuthEvent] ->
    AuthUnitOfWork m Bool

type instance DispatchOf AuthUnitOfWork = Dynamic

-- | Atomically persist a new session, its first refresh token, and the events naming it.
persistNewSession ::
  (AuthUnitOfWork :> es) =>
  NewSession ->
  NewSessionToken ->
  (SessionId -> [AuthEvent]) ->
  Eff es (Session, PersistedRefreshToken)
persistNewSession ns nst mkEvents = send (PersistNewSession ns nst mkEvents)

-- | Atomically retire a refresh token and issue its replacement, or report a conflict.
rotateRefreshToken ::
  (AuthUnitOfWork :> es) =>
  RefreshTokenId ->
  UTCTime ->
  NewRefreshToken ->
  AuthEvent ->
  Eff es RotationOutcome
rotateRefreshToken rid usedAt nrt ev = send (RotateRefreshToken rid usedAt nrt ev)

-- | Atomically complete a password reset after the caller has computed and validated the hash.
completePasswordReset ::
  (AuthUnitOfWork :> es) =>
  PasswordResetTokenId ->
  UserId ->
  PasswordHash ->
  UTCTime ->
  [AuthEvent] ->
  Eff es Bool
completePasswordReset tid uid newHash ts events =
  send (CompletePasswordReset tid uid newHash ts events)

-- | Atomically complete a password change after the caller has computed and validated the hash.
completePasswordChange ::
  (AuthUnitOfWork :> es) =>
  UserId ->
  PasswordHash ->
  UTCTime ->
  [AuthEvent] ->
  Eff es ()
completePasswordChange uid newHash ts events =
  send (CompletePasswordChange uid newHash ts events)

-- | Atomically revoke an active session, its refresh tokens, and the supplied audit events.
revokeSessionWithTokens ::
  (AuthUnitOfWork :> es) =>
  SessionId ->
  UTCTime ->
  [AuthEvent] ->
  Eff es Bool
revokeSessionWithTokens sid ts events = send (RevokeSessionWithTokens sid ts events)