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 <integer><s|m|h|d> ('30m', '8h', '7d'). Any other spelling ('1.5h', '8 hours') silently falls back to 8 hours. |
A hook returns a session user shaped { id: string; name?: string; roles?: string[] }, or null to deny. name (else id) is the actor the audit log records for every mutating action.
roles is interpreted. A session with an empty or absent roles list has full access. A session with a non-empty list is a viewer unless the list contains operator or admin (case-insensitive): it can read everything, but every mutating API route (retry, cancel, bulk, fix-and-replay, signal, update, task completion, schedule control) answers 403. So map your own roles onto these two names, or leave roles out, rather than passing your app's role names through unchanged.
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 five 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 |
POST | <path>/logout | always | the same, for clients that prefer a non-GET logout |
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.
POST <path>/login and POST <path>/session are rate-limited per client IP: past 10 attempts a minute they answer 429. The window is kept in memory per instance, so a cluster multiplies the budget by its pod count.
The routes are named durable_dashboard.login.page, durable_dashboard.login.submit, durable_dashboard.session.mint, durable_dashboard.logout and durable_dashboard.logout.post.
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. The page's own fetch sends JSON instead and gets JSON back: 200 { redirectTo } on success, 400 when username or password is not a string, 401 { error } otherwise. The password is passed to your hook as typed (an empty string when blank), so a hook that gates on the username alone works with the same page. 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 — and always secure under NODE_ENV=production, even when a TLS-terminating proxy hides the scheme from AdonisJS.
The cookie is not renewed while the operator works: it expires ttl after it was minted, and the operator signs in (or is sent through your app) again. A user you deactivate or demote in your app keeps the access the cookie carries until it expires, so keep ttl short when that matters.
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 bundled console has an internal helper that calls it, but it is not published as an importable entry point. Write the call yourself, as below — 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
// Map onto the console's two roles: `operator` may act, anything else is read-only.
return { id: user.id, name: user.name, roles: user.roles.includes('ops-admin') ? ['operator'] : ['viewer'] }
},
},
})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; a denial (the hook returned null or threw) answers 401 { "error": "unauthorized" }. 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 how events from other instances arrive), and engine.use for onion middleware around a local step. Plus currentStep() for step logs, collectMetrics for a Prometheus endpoint and attachDurableDiagnostics for the diagnostics bus.