Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6fbd634
Remove ControllerContext TMap, delete ihp-context package
mpscholten Apr 13, 2026
00d1c58
Fix list numbering in authentication guide after splitting step 6
mpscholten Apr 13, 2026
e4870ad
Drop unused imports flagged by CI's Werror=unused-imports
mpscholten Apr 13, 2026
2660a99
Drop unused Data.TMap imports from test files
mpscholten Apr 13, 2026
a77a2ab
Use NoFieldSelectors on ControllerContext to avoid clash with request fn
mpscholten Apr 13, 2026
38af3f1
Make ControllerContext a newtype
mpscholten Apr 13, 2026
d1c0178
Temporarily point Bench at ihp-forum branch with auth migration
mpscholten Apr 13, 2026
269154e
Pin ihp-forum to specific commit sha for cache-busting
mpscholten Apr 13, 2026
a5feaaf
Bump ihp-forum pin to 2dfa843 with CurrentUserRecord instances imported
mpscholten Apr 13, 2026
4c7910c
Bump ihp-forum pin to 62e707e (TypeInstances cycle fix)
mpscholten Apr 13, 2026
dedec78
Collapse ControllerContext to a type alias for Request
mpscholten Apr 13, 2026
7367501
Drop unused IHP.Controller.Context import in RouterSupport
mpscholten Apr 13, 2026
689c155
Drop unused imports and update test type signatures to Request
mpscholten Apr 13, 2026
e183422
Delete IHP.Controller.Context module — type alias inlined into Contro…
mpscholten Apr 13, 2026
c5316c9
Re-trigger CI
mpscholten Apr 13, 2026
d0107e8
Trigger CI again
mpscholten Apr 13, 2026
c734fbc
bump
mpscholten Apr 13, 2026
df2724f
Merge remote-tracking branch 'origin/master' into worktree-remove-con…
mpscholten Apr 13, 2026
fe5fba5
Override ihp-zip with fork that drops removed IHP.Controller.Context …
mpscholten Apr 13, 2026
f7160c6
Bump ihp-zip to 0.1.1 from Hackage, drop overlay override
mpscholten Apr 13, 2026
bba8d8d
Drop duplicate ?context :: Request from signatures that also have ?re…
mpscholten Apr 13, 2026
6606279
Rename ?context :: Request to ?request :: Request in framework internals
mpscholten Apr 14, 2026
2bacccb
Merge pull request #2636 from digitallyinduced/worktree-remove-contex…
mpscholten Apr 14, 2026
a4f37bb
Fix ihp-job-dashboard build: shim ?context in buildBaseJobTable, dedu…
mpscholten Apr 14, 2026
76e8e58
Restore ?context :: Request on RLS DataSync helpers
mpscholten Apr 14, 2026
7d2573f
Fix more ?context propagation in DataSync ControllerImpl
mpscholten Apr 14, 2026
20a835b
Shim ?context in DataSync Controller WSApp.run
mpscholten Apr 14, 2026
800fbd2
Shim ?context in ihp-ssc setState (calls SSC.render which needs ?cont…
mpscholten Apr 14, 2026
ebd201d
Shim ?context in ihp-ssc ComponentsController.run for Log calls
mpscholten Apr 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Guide/architecture.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -307,15 +307,16 @@ The router maps URLs like `/Posts` to `PostsAction`, `/ShowPost?postId=...` to `

### Step 7: initContext Runs

Before your action code runs, IHP calls `initContext` from your application's `InitControllerContext` instance. This is where you set up shared controller state, such as loading the currently logged-in user:
Before your action code runs, IHP calls `initContext` from your application's `InitControllerContext` instance. This is where you set up shared controller state, such as the default layout:

```haskell
instance InitControllerContext WebApplication where
initContext = do
setLayout defaultLayout
initAuthentication @User
```

Authentication runs earlier as a WAI middleware (`AuthMiddleware (authMiddleware @User)` in `Config.hs`) so the current user is already in the request vault by the time `initContext` runs.

If `initContext` throws an exception (for example, if authentication redirects to a login page), the action is never called.

### Step 8: beforeAction Runs
Expand Down
16 changes: 9 additions & 7 deletions Guide/authentication.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -887,7 +887,7 @@ config = do

### How Login and Logout Work

When a user logs in, IHP stores the user's ID in the session under the key `login.User` (or `login.Admin` for admin authentication). The key is constructed from the model name, not the table name. The `initAuthentication @User` call in your `FrontController.hs` reads this session value on each request and fetches the corresponding user record from the database.
When a user logs in, IHP stores the user's ID in the session under the key `login.User` (or `login.Admin` for admin authentication). The key is constructed from the model name, not the table name. The `authMiddleware @User` middleware (configured in `Config.hs`) reads this session value on each request and fetches the corresponding user record from the database.

When a user logs out, IHP sets the session value for `login.User` to an empty string. The session cookie itself remains, but the user ID is cleared.

Expand Down Expand Up @@ -980,17 +980,19 @@ Here is a summary of every change needed to add authentication. Use this as a re
- Implement the login form view

6. In `Web/FrontController.hs`:
- Add `import IHP.LoginSupport.Middleware`
- Add `import Web.Controller.Sessions`
- Mount the controller: `parseRoute @SessionsController`
- Add `initAuthentication @User` to `initContext`

7. Add `ensureIsUser` to `beforeAction` in any controller that requires login.
7. In `Config/Config.hs`:
- Add `import IHP.LoginSupport.Middleware`
- Add `option $ AuthMiddleware (authMiddleware @User)`

8. Add `ensureIsUser` to `beforeAction` in any controller that requires login.

8. Add a logout link in your layout: `<a class="js-delete js-delete-no-confirm" href={DeleteSessionAction}>Logout</a>`
9. Add a logout link in your layout: `<a class="js-delete js-delete-no-confirm" href={DeleteSessionAction}>Logout</a>`

9. (Optional) Create a registration controller and view for user sign-up.
10. (Optional) Create a registration controller and view for user sign-up.

10. (Optional) Set up password reset flow with token generation and email.
11. (Optional) Set up password reset flow with token generation and email.

[Next: Authorization](https://ihp.digitallyinduced.com/Guide/authorization.html)
3 changes: 1 addition & 2 deletions Guide/authorization.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -520,13 +520,12 @@ This approach gives you full control over what the user sees and where they are

For an additional layer of protection, IHP supports PostgreSQL Row-Level Security (RLS). With RLS, the database itself enforces that users can only access rows they are authorized to see, regardless of what your application code does.

See the [IHP DataSync documentation](https://ihp.digitallyinduced.com/Guide/realtime-spas.html) for details on how to set up RLS policies. In your `FrontController.hs`, call `enableRowLevelSecurityIfLoggedIn` after `initAuthentication`:
See the [IHP DataSync documentation](https://ihp.digitallyinduced.com/Guide/realtime-spas.html) for details on how to set up RLS policies. With `AuthMiddleware (authMiddleware @User)` enabled in `Config.hs`, call `enableRowLevelSecurityIfLoggedIn` from your `FrontController.hs`:

```haskell
instance InitControllerContext WebApplication where
initContext = do
setLayout defaultLayout
initAuthentication @User
enableRowLevelSecurityIfLoggedIn
```

Expand Down
5 changes: 3 additions & 2 deletions Guide/passkeys.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -340,10 +340,11 @@ instance FrontController WebApplication where
]

instance InitControllerContext WebApplication where
initContext = do
initAuthentication @User
initContext = pure ()
```

In `Config/Config.hs` add `option $ AuthMiddleware (authMiddleware @User)` so the current user is loaded into the request vault on every request.

### Sessions Controller

Create `Web/Controller/Sessions.hs` for logout:
Expand Down
6 changes: 2 additions & 4 deletions Guide/recipes.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,7 @@ The `DeleteSessionAction` expects a `HTTP DELETE` request, which is set by JavaS

## Making a dynamic Login/Logout button

Depending on the `Maybe User` type in the [ControllerContext](https://ihp.digitallyinduced.com/api-docs/IHP-Controller-Context.html), by using [`fromFrozenContext`](https://ihp.digitallyinduced.com/api-docs/IHP-Controller-Context.html#v:fromFrozenContext) we can tell if no user is logged in when the `Maybe User` is `Nothing`, and confirm someone is logged in if the `Maybe User` is a `Just user`. Here is an example of a navbar, which has a dynamic Login/Logout button. You can define this in your View/Layout to reuse this in your Views.

> The `@` syntax from [`fromFrozenContext @(Maybe User)`](https://ihp.digitallyinduced.com/api-docs/IHP-Controller-Context.html#v:fromFrozenContext) is just syntax sugar for `let maybeUser :: Maybe User = fromFrozenContext`
Use [`currentUserOrNothing`](https://ihp.digitallyinduced.com/api-docs/IHP-LoginSupport-Helper-View.html#v:currentUserOrNothing) to check whether someone is logged in. It returns `Just user` when a user is authenticated and `Nothing` otherwise. Here is an example of a navbar with a dynamic Login/Logout button that you can place in your View/Layout to reuse across your views.

```haskell
navbar :: Html
Expand All @@ -259,7 +257,7 @@ navbar = [hsx|
where
loginLogoutButton :: Html
loginLogoutButton =
case fromFrozenContext @(Maybe User) of
case currentUserOrNothing of
Just user -> [hsx|<a class="js-delete js-delete-no-confirm text-secondary" href={DeleteSessionAction}>Logout</a>|]
Nothing -> [hsx|<a class="text-secondary" href={NewSessionAction}>Login</a>|]
```
Expand Down
39 changes: 30 additions & 9 deletions Guide/seo.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -206,23 +206,44 @@ defaultLayout inner = [hsx|
|]
```

If you want per-page Twitter tags, you can use the same `putContext`/`fromFrozenContext` pattern described in the [Views documentation](view.html) for layout variables.
If you want per-page Twitter tags, you can use the same vault-key pattern described in the [Views documentation](view.html) for layout variables.

## Canonical URLs

A canonical URL tells search engines which version of a page is the "official" one. This is important when the same content can be reached through multiple URLs (for example, with and without query parameters, or with different sorting options). Without a canonical tag, search engines might index duplicate pages and dilute your ranking.

### Adding a Canonical Tag to the Layout

Since IHP does not have a built-in `setCanonical` helper, you can use the `putContext`/`fromFrozenContext` pattern to pass a canonical URL from your view to the layout.
Since IHP does not have a built-in `setCanonical` helper, you can store the canonical URL in the WAI request vault and read it back in the layout.

First, create a newtype to store the canonical URL. You can add this to `Web/View/Layout.hs` or a shared module:
First, declare a vault key and a tiny middleware to set it:

```haskell
-- Web/View/Layout.hs (or a shared module)
import qualified Data.Vault.Lazy as Vault
import Network.Wai (Request, vault)
import System.IO.Unsafe (unsafePerformIO)

newtype CanonicalUrl = CanonicalUrl Text

canonicalUrlVaultKey :: Vault.Key (IORef (Maybe CanonicalUrl))
canonicalUrlVaultKey = unsafePerformIO Vault.newKey
{-# NOINLINE canonicalUrlVaultKey #-}

setCanonical :: (?request :: Request) => Text -> IO ()
setCanonical url = case Vault.lookup canonicalUrlVaultKey (vault ?request) of
Just ref -> writeIORef ref (Just (CanonicalUrl url))
Nothing -> pure ()

currentCanonical :: (?request :: Request) => Maybe CanonicalUrl
currentCanonical = case Vault.lookup canonicalUrlVaultKey (vault ?request) of
Just ref -> unsafePerformIO (readIORef ref)
Nothing -> Nothing
```

In your layout, read the canonical URL from the context and render it if present:
Add `insertNewIORefVaultMiddleware canonicalUrlVaultKey Nothing` to your `Config/Config.hs` middleware stack so the IORef is created on every request.

In your layout, read the canonical URL from the vault and render it if present:

```haskell
defaultLayout :: Html -> Html
Expand All @@ -240,21 +261,21 @@ defaultLayout inner = [hsx|
</html>
|]

canonicalTag :: (?context :: ControllerContext) => Html
canonicalTag = case maybeFromFrozenContext @CanonicalUrl of
canonicalTag :: (?request :: Request) => Html
canonicalTag = case currentCanonical of
Just (CanonicalUrl url) -> [hsx|<link rel="canonical" href={url}>|]
Nothing -> mempty
```

### Setting a Canonical URL from a Controller

Set the canonical URL by calling `putContext` in your action:
Call `setCanonical` in your action:

```haskell
instance Controller PostsController where
action ShowPostAction { postId } = do
post <- fetch postId
putContext (CanonicalUrl (urlTo ShowPostAction { postId = post.id }))
setCanonical (urlTo ShowPostAction { postId = post.id })
render ShowView { .. }
```

Expand Down Expand Up @@ -604,7 +625,7 @@ instance View ShowView where
Just url -> setOGImage url
Nothing -> pure ()

putContext (CanonicalUrl (urlTo ShowPostAction { postId = post.id }))
setCanonical (urlTo ShowPostAction { postId = post.id })

html ShowView { post } = [hsx|
<h1>{post.title}</h1>
Expand Down
2 changes: 1 addition & 1 deletion Guide/session.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ The rendered HTML looks like this:
<div class="alert alert-danger">{errorMessage}</div>
```

To display the Flash Messages in a custom way, you can always access them using `let flashMessages :: [FlashMessage] = fromFrozenContext` in your views. This returns a list of [`FlashMessage`](https://ihp.digitallyinduced.com/api-docs/IHP-FlashMessages-Types.html#t:FlashMessage). You can also take a look at the [`renderFlashMessages`](https://ihp.digitallyinduced.com/api-docs/IHP-FlashMessages-ViewFunctions.html#v:renderFlashMessages) implementation and copy the code into your view, and then make customizations.
To display the Flash Messages in a custom way, call `requestFlashMessages ?request` in your view. This returns a list of [`FlashMessage`](https://ihp.digitallyinduced.com/api-docs/IHP-FlashMessages-Types.html#t:FlashMessage). You can also take a look at the [`renderFlashMessages`](https://ihp.digitallyinduced.com/api-docs/IHP-FlashMessages-ViewFunctions.html#v:renderFlashMessages) implementation and copy the code into your view, and then make customizations.

## Session Cookie

Expand Down
60 changes: 28 additions & 32 deletions Guide/view.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -115,36 +115,41 @@ Here's some examples:

In all of these cases you don't want to deal with passing the information to the layout inside every action of your application.

The general idea is that we store the needed information inside the controller context. The controller context is an implicit parameter that is passed around via the `?context` variable during the request response lifecycle. Think of it as a key-value map which you can write to before rendering, and read from during the view rendering.
The general idea is to store the needed information in the WAI request vault. A WAI middleware writes the value into the vault before your action runs; the layout reads it back through a small accessor function.

Let's deal with the first case: Our business application wants to display the user's company name as part of the layout on every page.

Open `Web/FrontController.hs` and customize it like this:
First, define a vault key and a middleware that fills it in. Put this somewhere you can import from both the layout and `Config.hs`:

```haskell
-- Web/FrontController.hs
-- Application/CompanyContext.hs

instance InitControllerContext WebApplication where
initContext = do
-- ...
import qualified Data.Vault.Lazy as Vault
import Network.Wai (Middleware, Request, vault)
import System.IO.Unsafe (unsafePerformIO)
import IHP.RequestVault.Helper (insertVaultMiddleware, lookupRequestVault)

initCompanyContext -- <---- ADD THIS
companyVaultKey :: Vault.Key (Maybe Company)
companyVaultKey = unsafePerformIO Vault.newKey
{-# NOINLINE companyVaultKey #-}

initCompanyContext :: (?context :: ControllerContext, ?modelContext :: ModelContext) => IO ()
initCompanyContext =
case currentUserOrNothing of
Just currentUser -> do
company <- fetch currentUser.companyId
-- | Fetches the current user's company and stores it in the vault.
companyMiddleware :: ModelContext -> Middleware
companyMiddleware modelContext app req respond = do
let ?modelContext = modelContext
let ?request = req
company <- case currentUserOrNothing of
Just user -> Just <$> fetch user.companyId
Nothing -> pure Nothing
let req' = req { vault = Vault.insert companyVaultKey company (vault req) }
app req' respond

-- Here the magic happens: We put the company of the user into the context
putContext company

Nothing -> pure ()
-- | Read the current company from any view or controller.
currentCompany :: (?request :: Request) => Maybe Company
currentCompany = fromMaybe Nothing (Vault.lookup companyVaultKey (vault ?request))
```

The [`initContext`](https://ihp.digitallyinduced.com/api-docs/IHP-ControllerSupport.html#v:initContext) is called on every request, just before the action is executed. The `initCompanyContext` fetches the current user's company and then calls [`putContext company`](https://ihp.digitallyinduced.com/api-docs/IHP-Controller-Context.html#v:putContext) to store it inside the controller context.

Next we'll read the company from the `Layout.hs`:
Then wire `companyMiddleware` into your `Config/Config.hs` after `AuthMiddleware`, and read it from the layout:

```haskell
-- Web/View/Layout.hs
Expand All @@ -153,27 +158,18 @@ defaultLayout :: Html -> Html
defaultLayout inner = [hsx|
{inner}

{when isLoggedIn renderCompany}
{forEach currentCompany renderCompany}
|]
where
isLoggedIn = isJust currentUserOrNothing

renderCompany :: Html
renderCompany = [hsx|
renderCompany :: Company -> Html
renderCompany company = [hsx|
<div class="company">
{company.name}
</div>
|]

company :: (?context :: ControllerContext) => Company
company = fromFrozenContext
```

Here the company is read by using the [`fromFrozenContext`](https://ihp.digitallyinduced.com/api-docs/IHP-Controller-Context.html#v:fromFrozenContext) function.

You might wonder: How does [`fromFrozenContext`](https://ihp.digitallyinduced.com/api-docs/IHP-Controller-Context.html#v:fromFrozenContext) know that I want the company? The context is a key-value map, where the key's are the type of the object. Using the `company :: Company` type annotation the [`fromFrozenContext`](https://ihp.digitallyinduced.com/api-docs/IHP-Controller-Context.html#v:fromFrozenContext) knows we want to read the value with the key `Company`.

Now the `company` variable can be used to read the current user's company across the layout and also in all views (you need to add `company` to the export list of the Layout module for that). If the `company` value is used somewhere during rendering while the user is not logged in, it will raise a runtime error.
Why a vault key per piece of state? The request vault is the single source of truth for per-request data. A dedicated `Vault.Key Company` makes the dependency explicit, type-safe, and trivial to look up from any view or controller without going through the controller context.

## Common View Tasks

Expand Down
4 changes: 2 additions & 2 deletions NixSupport/hackage/ihp-zip.nix
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{ mkDerivation, base, http-types, ihp, lib, wai, zip-archive }:
mkDerivation {
pname = "ihp-zip";
version = "0.1.0";
sha256 = "3ff75acfca08231d2ea365369a42b4b8f1abf05df64a980116eed193a778d860";
version = "0.1.1";
sha256 = "1hkx1rf4h297bjjwwf6ckxg6jp7bvr2z92vy4a67n33k8l7mhi18";
libraryHaskellDepends = [ base http-types ihp wai zip-archive ];
homepage = "https://ihp.digitallyinduced.com/";
description = "Support for making ZIP archives with IHP";
Expand Down
1 change: 0 additions & 1 deletion NixSupport/overlay.nix
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ let
in {
ihp = localPackage "ihp";
ihp-with-docs = localPackageWithHaddock "ihp";
ihp-context = localPackage "ihp-context";
ihp-pagehead = localPackage "ihp-pagehead";
ihp-log = localPackage "ihp-log";
ihp-pglistener = localPackage "ihp-pglistener";
Expand Down
28 changes: 26 additions & 2 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ The `renderJson` function is unchanged and can still be used directly in control

## Authentication moved to WAI middleware

The `initAuthentication` function has been deprecated in favor of a WAI middleware approach. Authentication now runs as middleware before your controllers, storing the current user in the WAI request vault.
The `initAuthentication` function has been removed in favor of a WAI middleware approach. Authentication now runs as middleware before your controllers, storing the current user in the WAI request vault.

**Migration steps:**

Expand Down Expand Up @@ -68,7 +68,31 @@ The `initAuthentication` function has been deprecated in favor of a WAI middlewa
option $ AuthMiddleware (authMiddleware @User . adminAuthMiddleware @Admin)
```

**Deprecated functions:** `initAuthentication` still works but is deprecated. `currentRoleOrNothing`, `currentRole`, `currentRoleId`, `ensureIsRole` have been removed. Use the type-specific variants instead: `currentUserOrNothing`/`currentAdminOrNothing`, `currentUser`/`currentAdmin`, `currentUserId`/`currentAdminId`, `ensureIsUser`/`ensureIsAdmin`.
**Removed functions:** `initAuthentication`, `currentRoleOrNothing`, `currentRole`, `currentRoleId`, `ensureIsRole`. Use the type-specific variants instead: `currentUserOrNothing`/`currentAdminOrNothing`, `currentUser`/`currentAdmin`, `currentUserId`/`currentAdminId`, `ensureIsUser`/`ensureIsAdmin`.

## ControllerContext TMap API removed

The typed-map storage on `ControllerContext` has been removed. The functions `putContext`, `fromContext`, `maybeFromContext`, `fromFrozenContext`, `maybeFromFrozenContext`, `freeze`, and `unfreeze` no longer exist, and the `FrozenControllerContext` constructor is gone. The `ihp-context` package has also been deleted — drop it from your `cabal.project`/`build-depends` if you referenced it directly.

`ControllerContext` is now a thin wrapper around the WAI `Request`. All per-request state (auth user, framework config, logger, page head, modal state, ...) lives in the request vault. To store your own per-request value, define a `Vault.Key` and a small middleware:

```haskell
import qualified Data.Vault.Lazy as Vault
import IHP.RequestVault.Helper (insertVaultMiddleware, lookupRequestVault)
import System.IO.Unsafe (unsafePerformIO)

myValueVaultKey :: Vault.Key MyValue
myValueVaultKey = unsafePerformIO Vault.newKey
{-# NOINLINE myValueVaultKey #-}

-- In Config.hs:
option $ CustomMiddleware (insertVaultMiddleware myValueVaultKey someValue)

-- In a controller or view:
let value = lookupRequestVault myValueVaultKey ?request
```

If you need mutable per-request state, store an `IORef` in the vault (use `insertNewIORefVaultMiddleware`). See how `IHP.LoginSupport.Types.currentUserVaultKey`, `IHP.RequestVault.loggerVaultKey`, and `IHP.PageHead.Types.pageHeadVaultKey` are defined for working examples.

## Join Support Removed from QueryBuilder

Expand Down
1 change: 0 additions & 1 deletion cabal.project
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
packages:
ihp/
ihp-context/
ihp-hsx/
ihp-log/
ihp-modal/
Expand Down
Loading