Dashboard auth
The console's optional session layer (dashboardAuth) — sign operators in at a built-in login page, or mint a session straight from your already-authenticated app. A signed HMAC cookie gates the pages (302 or 401) and the JSON API (401). Opt-in, additive to authorize, fails closed at boot.
By default the console is gated only by the per-request authorize guard — fine when you already have a bearer token or your own session middleware. When you'd rather operators arrive with a real session, opt into dashboardAuth: a signed session cookie minted through a hook you supply. It ships in @adonis-agora/durable — nothing extra to install.
dashboardAuth is additive. It composes with authorize (both must pass) — it does not replace it. Omit it and the console behaves exactly as before: no auth routes, no session cookie.
Two ways to get a session
There are two hooks, and you may configure either one or both.
Mode A — session: open the console from your app. The operator is already signed in to your application. Your app calls the console's session endpoint, which hands your hook the raw request so it can read whatever it already trusts — your own cookie, a JWT, a header — and returns the operator's identity. No second password, no second login page. This is the right shape when the console is a page inside a product an admin is already using.
Mode B — login: a standalone sign-in page. The console serves its own server-rendered username/password page and calls your hook to verify the credentials. The right shape when the console stands alone, with no host app in front of it.
Configuring both gives operators a login page and a way in from the host app.
Enable it
Add a dashboardAuth block to config/durable_dashboard.ts. secret is always required, plus at least one of session / login:
import { defineConfig } from '@adonis-agora/durable/dashboard'
import env from '#start/env'
import User from '#models/user'
export default defineConfig({
dashboardAuth: {
secret: env.get('DURABLE_DASHBOARD_SECRET'), // HMAC-SHA256 signing key, 32+ bytes
ttl: '8h',
// Mode B — the built-in sign-in page.
login: async (username, password) => {
const user = await User.verifyCredentials(username, password).catch(() => null)
if (!user || !user.isAdmin) return null // return null to deny
return { id: String(user.id), name: user.fullName, roles: ['admin'] }
},
},
})Both hooks are plain functions — they may be async and close over your app's services (import them at the top of the config file), so there's nothing extra to wire for the "reach a DB or auth service" case.
| Field | Required | Default | Description |
|---|---|---|---|
secret | yes | — | HMAC-SHA256 key the session cookie is signed with. Missing or empty fails closed at boot. |
session | one of the two | — | (request) => DashboardSessionUser | null (may be async). Receives the raw Node request. Mode A. |
login | one of the two | — | (username, password) => DashboardSessionUser | null (may be async). Mode B. |
ttl | no | '8h' | Cookie lifetime as a duration string ('30m', '8h', '7d'). |
A hook returns a session user shaped { id: string; name?: string; roles?: string[] }, or null to deny. roles is carried in the cookie for your own use — the console does not interpret it.
Validated at boot, so a mis-wired gate cannot ship
Two conditions throw at boot rather than at the first request: an absent or empty secret, and neither session nor login being a function. Keep secret out of source — read it from an env var.
What it adds
When dashboardAuth is configured, the provider mounts up to four public endpoints under the console's path. They mint or clear the session the guard checks for, so they sit in front of it — and, importantly, in front of authorize too.
| Method | Route | Mounted when | Purpose |
|---|---|---|---|
POST | <path>/session | session is configured | hands the raw request to your session hook and sets the cookie |
GET | <path>/login | login is configured | the server-rendered sign-in page |
POST | <path>/login | login is configured | verifies credentials via your login hook and sets the cookie |
GET | <path>/logout | always | clears the cookie and redirects |
And it stamps a session guard onto every console route. Without a valid cookie:
- a page navigation is redirected
302to<path>/login(carrying a sanitizedreturnTo) whenloginis configured, and answered with the401access-denied page ("Open this console from your app") when onlysessionis; - an API request gets
401 { "error": "unauthorized", "auth": { "modes": [...] } }. Themodesarray tells a client which way in exists — the console reads it to decide between redirecting to the login page and bouncing to the console root.
logout redirects to <path>/login when a login page exists, and to the console root otherwise.
The login page is a plain HTML form that works without JavaScript: a form submit is answered with a redirect — to the page the operator came from (returnTo, same-origin only), or back to the form with the error shown. Its inline script (which upgrades the submit to a fetch) and style carry @adonisjs/shield's request nonce, so a script-src 'self' @nonce policy keeps the page working.
The session is a stateless, signed HMAC-SHA256 cookie (durable_dashboard_session) — no server-side session store. It carries the user id, optional name and roles, plus issued-at and expiry, and is rejected once tampered with or expired. It is httpOnly, sameSite=lax, scoped to /, and secure on an HTTPS request.
Every credential failure — unknown user, wrong password, or a throwing hook — returns the same uniform 401 ("Invalid username or password."), so the endpoint does not leak whether a username exists. A throwing hook is warn-logged once per process server-side, but never changes the client-visible response.
Mode A: opening the console from your app
This is the flow where the operator never sees a second login. Your app already knows who they are; the console's job is to accept that.
Your integration point is the route, not a helper
The supported, stable contract is POST <path>/session. The console's SPA package ships a browser helper that calls it, and you are welcome to use it, but it is a convenience wrapper — an internal detail of the bundled client, not the API. Anything that can issue an HTTP request can open the console.
1. Write the session hook
The hook receives the raw Node IncomingMessage for the POST <path>/session request, so it can read whatever your app already trusts on it. Return the operator's identity, or null to deny:
import { defineConfig } from '@adonis-agora/durable/dashboard'
import { parse as parseCookie } from 'node:querystring'
import env from '#start/env'
import { verifyAppSession } from '#services/app_session'
export default defineConfig({
dashboardAuth: {
secret: env.get('DURABLE_DASHBOARD_SECRET'),
ttl: '8h',
session: async (request) => {
const req = request as import('node:http').IncomingMessage
// Read whatever your app already trusts — here, its own session cookie.
const raw = req.headers.cookie ?? ''
const token = parseCookie(raw.replaceAll('; ', '&')).app_session
if (typeof token !== 'string') return null
const user = await verifyAppSession(token)
if (!user || !user.roles.includes('ops')) return null
return { id: user.id, name: user.name, roles: user.roles }
},
},
})Note that the request reaches your hook exactly as the browser sent it, so a cross-origin call must include credentials for your app's cookie to be there at all.
2. Mint the session, then navigate
From your already-authenticated app, POST to the session route and only then send the operator to the console. The POST sets the cookie the console's guard checks for; the navigation that follows arrives authenticated:
export async function openDurableConsole() {
const res = await fetch('/durable/session', {
method: 'POST',
credentials: 'include', // your app's cookie must ride along
redirect: 'manual', // an auth middleware answering 3xx is a failure, not a session
})
if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) {
throw new Error('Session mint was redirected — the operator is not signed in.')
}
if (!res.ok) {
throw new Error(`Could not open the durable console (HTTP ${res.status}).`)
}
window.location.assign('/durable')
}A successful mint answers 204 with an empty body. It takes no request body — everything your hook needs is already on the request.
Handle the redirect case deliberately: if your own auth middleware turns an unauthenticated POST into a 302 to your sign-in page, fetch would follow it and report a cheerful 200 for an HTML login form. redirect: 'manual' turns that into the failure it actually is.
3. Give operators a way out
GET <path>/logout clears the cookie. In a Mode-A-only setup it redirects to the console root, which the guard then answers with the "open this console from your application" page — the console has no sign-in of its own to return to. A plain link is enough, since the route is idempotent and only clears the caller's own session:
<a href="/durable/logout">Sign out of the console</a>Choosing between the guards
- Machine-to-machine, or you already have app auth in front of the console → keep the default
authorize(or your own guard) and skipdashboardAuthentirely. See Authorization. - Operators are already signed in to your app and should reach the console in one click → Mode A, the
sessionhook. - The console stands alone and operators need somewhere to type a username and password → Mode B, the
loginhook. - Both, when some operators arrive from the app and others go straight to the console.
All of these layer on top of authorize, so you can keep an IP allow-list or an environment gate there and require a session on top of it.
Dashboard
The embedded operations console — a React SPA plus a JSON API mounted into your AdonisJS routes. Filter runs, live-tail a run's timeline, fix-and-replay a bad input, deliver signals/updates/task completions, pause and trigger schedules, act on runs in bulk, and read the worker fleet's health — with the whole wire contract published as OpenAPI 3.1.
Events & interceptors
The two programmatic hooks into a running engine — engine.subscribe for the lifecycle event stream (every EngineEvent type and its payload), and engine.use for onion middleware around a local step. Plus collectMetrics for a Prometheus endpoint and attachDurableDiagnostics for the diagnostics bus.