Dashboard login & sessions
Practical dashboardAuth — login mode validating against your own user table with bcrypt, session mode bridging an existing JWT, and role-gating queue mutations via request.telescopeSession.
dashboardAuth gates the Telescope dashboard to your logged-in admins with no extra infra. The auth reference explains the signed-cookie mechanism and the endpoint contract — this recipe is the practical wiring for three real setups: login against your user table, bridging an existing JWT session, and role-gating mutations.
Both modes mint the same stateless telescope_session cookie; a host hook returns a TelescopeSessionUser ({ id, name?, roles? }) to mint it, or null to deny.
Recipe A — login against your own user table
login mode renders Telescope's built-in login screen and calls your hook with the submitted credentials. Validate them against your real users with a proper password compare and a role check — no second auth system:
import { Module } from '@nestjs/common';
import { TelescopeModule } from '@dudousxd/nestjs-telescope';
import { compare } from 'bcryptjs';
import { UsersService } from './users/users.service';
@Module({
imports: [
TelescopeModule.forRootAsync({
inject: [UsersService],
useFactory: (users: UsersService) => ({
enabled: true,
dashboardAuth: {
secret: process.env.TELESCOPE_AUTH_SECRET,
ttl: '8h',
login: async (username, password) => {
const user = await users.findByEmail(username); // your repo lookup
if (user === null) return null; // unknown user
const ok = await compare(password, user.passwordHash);
if (!ok) return null; // bad password
if (!user.roles.includes('admin')) return null; // not allowed in
return { id: user.id, name: user.name, roles: user.roles };
},
},
}),
}),
],
})
export class AppModule {}How it works
login(username, password)receives the raw submitted credentials. Return aTelescopeSessionUserto mint the cookie, ornullto deny — Telescope answers a uniform401onnull(no user enumeration), so you don't need to distinguish "unknown user" from "bad password" in the response.- Compare hashes, never plaintext.
bcryptjs.compareis constant-time against the stored hash. - Gate on role here. Returning
nullfor a non-admin keeps non-admins out of the dashboard entirely. Therolesyou return ride in the session and are readable later for mutation gating (Recipe C). - A hook that throws is treated as a denial (
null) with a once-per-kind warn — it never500s the endpoint into a stack-trace leak.
Throttle the login endpoint
Brute-force throttling on POST /telescope/api/auth/login is the host's job — wrap it with Nest's Throttler (or your gateway's rate limit). The /auth/* endpoints do no heavy work before your hook runs, but the hook itself does a DB lookup and a bcrypt compare.
Recipe B — bridge an existing JWT/Bearer session
session mode is for when your own frontend already holds the host's auth (e.g. a Keycloak Bearer JWT). Your frontend mints the Telescope cookie with one authenticated fetch, then opens the dashboard — no second login.
The hook receives the raw request, verifies your own Bearer token, and returns the user (or null):
import { TelescopeModule } from '@dudousxd/nestjs-telescope';
import { JwtService } from '@nestjs/jwt';
TelescopeModule.forRootAsync({
inject: [JwtService],
useFactory: (jwt: JwtService) => ({
enabled: true,
dashboardAuth: {
secret: process.env.TELESCOPE_AUTH_SECRET,
session: async (request) => {
// request is the raw Express/Fastify request — read your own header.
const header = (request as { headers?: Record<string, string> })
.headers?.authorization;
if (header === undefined || !header.startsWith('Bearer ')) return null;
try {
const claims = await jwt.verifyAsync(header.slice('Bearer '.length));
return claims.isAdmin
? { id: claims.sub, name: claims.name, roles: ['admin'] }
: null;
} catch {
return null; // invalid/expired token
}
},
},
}),
});How it works
session(request)runs onPOST /telescope/api/auth/session. It's the bridge: you verify the host's auth your way and hand back a session user. On a user it sets the cookie and returns204; onnullit returns401.- The SPA can't attach a Bearer header to its own navigations — that's why the cookie exists. One authenticated
POSTmints a same-origin, path-scoped cookie that rides along on every later SPA call with zero UI changes.
Recipe D — show your own page when there is no session
Under Mode A, a visitor who navigates straight to /telescope with no cookie gets the SPA, which then renders its built-in auth screen: "open this console from your application". That copy is generic because the library cannot know who hosts it — it can't name your launcher, link to it, or look like the rest of your product.
unauthenticatedPage hands that response to you — and, because the decision happens before the shell is served, the bundle never loads at all for someone with no session:
const dashboardAuth = {
secret: process.env.TELESCOPE_AUTH_SECRET!,
session: (request) => resolveAdmin(request),
unauthenticatedPage: ({ request, response, basePath }) => {
// `request`/`response` are your platform's own objects (Express here). Render however you
// already render — a template engine, @dudousxd/nestjs-inertia, a plain string.
(response as Response).status(401).render('console-locked', { returnTo: basePath });
},
};
TelescopeModule.forRoot({ enabled: true, dashboardAuth });
// The UI module serves the page, so it needs the same config — see the Callout below.
TelescopeUiModule.forRoot({ dashboardAuth });Pass dashboardAuth to TelescopeUiModule too. TelescopeModule (core) gates the JSON API; TelescopeUiModule (@dudousxd/nestjs-telescope-ui) serves the HTML shell, so it is the only one that can replace that page. Setting unauthenticatedPage on core alone changes nothing a visitor sees — exactly the same independence guards has between the two modules. Only secret, the configured modes and unauthenticatedPage are read there; minting and renewal stay core's job.
How it works
- The hook owns the response: it must write AND end it. The page is served at the dashboard's own URL, so
/telescopestays/telescope. - It cannot open the dashboard. It only runs when the request carries no valid session cookie, and every data route stays behind core's guard regardless.
- A broken page can't break the dashboard. If the hook throws, or returns without writing, the library logs one warning and serves the SPA as before — no hung request, no
500. - Hashed assets stay ungated — only the shell is gated. They carry no data.
- Mode-A-only. With
loginconfigured the hook is ignored: under Mode B the login form the visitor needs is inside the bundle this page would replace. To ship your own login UI, use Mode A plus this hook andPOST /telescope/api/auth/sessionfrom your page.
Recipe C — role-gate queue mutations
Reading the dashboard is gated by the session. Mutations (retry / remove / promote / redrive a job) are gated separately by authorizeAction, which defaults to deny. With a session in place it can read the roles you minted:
TelescopeModule.forRoot({
enabled: true,
dashboardAuth: {
secret: process.env.TELESCOPE_AUTH_SECRET,
login: /* ...Recipe A... */,
},
// Every queue mutation is 403 until this returns true. The verified session
// is attached as request.telescopeSession by the guard.
authorizeAction: ({ request }, action) => {
const session = (request as { telescopeSession?: { roles: string[] } })
.telescopeSession;
const roles = session?.roles ?? [];
// Only ops can redrive; admins can do everything; nobody else mutates.
if (action.action === 'redrive') return roles.includes('ops');
return roles.includes('admin');
},
});How it works
authorizeAction(ctx, action)is called for every mutation with the requestedaction—{ driver, queue, action, jobId?, state? }. Theaction.actionfield is the operation name (retry,remove,promote,retry-all,redrive,enqueue), so you can grant per-operation.request.telescopeSessionis the verified session the guard attached —{ sub, name?, roles, iat, exp }. Readrolesto branch.- It fails closed. A throwing hook denies; the default with no hook denies. A reader can browse but not mutate until you opt in.
- CSRF is covered by
SameSite=Lax— mutations arePOSTs, so the cookie won't ride a cross-site request.
authorizeAction and dashboardAuth are independent. You can run authorizeAction with no dashboardAuth (it reads whatever your authorizer lets through), but pairing them is what lets you say "admins read, ops redrive, nobody else mutates".
Capture @nestjs/axios traffic
HttpClientWatcher patches global fetch out of the box, but @nestjs/axios calls bypass it. Wire the axios source to capture HttpService and plain axios instances too — no monkey-patching.
Custom tags & redaction
Tag entries by tenant from a captured header, mask extra fields with redact.keys/paths and a custom mask, drop noisy entries with filter, and sample high-volume types.