@dudousxd/nestjs-durable-dashboard 0.36.0 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,32 +12,55 @@ interface DashboardSessionUser {
12
12
  roles?: string[];
13
13
  }
14
14
 
15
- /** Host hook validating submitted credentials from the built-in login page. */
15
+ /** Host hook for Mode A — validates the host's own auth on the raw request. */
16
+ type SessionHook = (request: unknown) => Promise<DashboardSessionUser | null> | DashboardSessionUser | null;
17
+ /** Host hook for Mode B — validates submitted credentials from the built-in login page. */
16
18
  type LoginHook = (username: string, password: string) => Promise<DashboardSessionUser | null> | DashboardSessionUser | null;
19
+ /**
20
+ * Re-checks a LIVE session when the cookie is slid forward. Runs at most once per `ttl/2` per
21
+ * session, so a DB round-trip here is cheap — this holds even under concurrent traffic: N parallel
22
+ * requests carrying the same past-half-life cookie share ONE call (see `revalidateOnce` in
23
+ * `session-cookie-io.ts`), not N. Return `false` to revoke (the cookie is cleared and the request
24
+ * denied). Distinct from `session`: that hook reads the host's auth off a fresh request, which a
25
+ * console XHR does not carry — this one receives the already-minted session.
26
+ */
27
+ type RevalidateHook = (session: DashboardSessionUser) => Promise<boolean> | boolean;
28
+ type AuthMode = 'session' | 'login';
17
29
  /**
18
30
  * Author-facing `dashboardAuth` option on `DurableDashboardModule.forRoot`/`forRootAsync`. Gates
19
31
  * BOTH the SPA (a full-page navigation redirected to a server-rendered login page) and the JSON
20
32
  * API (a plain `401`) behind a signed session cookie, mirroring `@dudousxd/nestjs-telescope`'s
21
- * `dashboardAuth` mechanism (same HMAC-SHA256 cookie, same fail-closed validation). Unlike
22
- * Telescope's dashboard — a client-rendered SPA that can grow its own login screen — the durable
23
- * dashboard's bundled React app stays untouched: the login screen here is a small, dependency-free
24
- * server-rendered HTML page (`GET <basePath>/login`), so gating the SPA shell itself with a real
25
- * redirect doesn't require rebuilding or extending the Vite bundle.
33
+ * `dashboardAuth` mechanism (same HMAC-SHA256 cookie, same fail-closed validation). Two ways to
34
+ * mint that cookie: Mode A (`session`) — the host frontend, already carrying its own auth, POSTs
35
+ * to `<basePath>/session` and the host hook decides; or Mode B (`login`) — the built-in,
36
+ * dependency-free server-rendered login page (`GET <basePath>/login`). At least one of the two is
37
+ * required so an un-mintable gate is a boot error, not a silently-open (or silently-stuck) console.
26
38
  */
27
39
  interface DashboardAuthOptions {
28
40
  /** REQUIRED HMAC-SHA256 signing key. Missing/empty => boot error (fail closed). */
29
41
  secret: string;
30
42
  /** Cookie TTL as a duration string (`'8h'`, `'30m'`, `'7d'`). Default `'8h'`. */
31
43
  ttl?: string;
32
- /** Validates submitted username/password; return the session user, or `null` to deny. Thrown
33
- * errors are treated as a denial (logged once, never surfaced to the client). */
34
- login: LoginHook;
44
+ /** Mode A: validates the host's own auth on the raw request POSTed to `<basePath>/session`;
45
+ * return the session user, or `null` to deny. Thrown errors are treated as a denial (logged
46
+ * once, never surfaced to the client). */
47
+ session?: SessionHook;
48
+ /** Mode B: validates submitted username/password from the built-in login page; return the
49
+ * session user, or `null` to deny. Thrown errors are treated as a denial (logged once, never
50
+ * surfaced to the client). */
51
+ login?: LoginHook;
52
+ /** Re-checks a live session on sliding renewal; see `RevalidateHook`. Not a mode — it cannot
53
+ * mint a session, only revoke one already minted by `session`/`login`. */
54
+ revalidate?: RevalidateHook;
35
55
  }
36
56
  /** Resolved, validated `dashboardAuth` config shared by the guards, auth controller, and login page. */
37
57
  interface ResolvedDashboardAuth {
38
58
  secret: string;
39
59
  ttlMs: number;
40
- login: LoginHook;
60
+ modes: AuthMode[];
61
+ session?: SessionHook;
62
+ login?: LoginHook;
63
+ revalidate?: RevalidateHook;
41
64
  }
42
65
 
43
66
  /**
@@ -195,21 +218,22 @@ declare class DurableAuthController {
195
218
  private readonly auth;
196
219
  private readonly basePath;
197
220
  private readonly logger;
198
- /** Warn once so a throwing `login` hook doesn't spam logs on every failed attempt. */
199
- private warnedOnHookThrow;
221
+ /** Warn once per hook kind so a throwing hook doesn't spam logs on every failed attempt. */
222
+ private readonly warnedHooks;
200
223
  constructor(auth: ResolvedDashboardAuth | null, basePath: string);
201
224
  loginPage(): string;
202
225
  login(body: LoginBody, request: unknown, response: unknown): Promise<{
203
226
  redirectTo: string;
204
227
  }>;
228
+ session(request: unknown, response: unknown): Promise<void>;
205
229
  logout(request: unknown, response: unknown): void;
206
230
  private requireAuth;
207
231
  /**
208
- * Run the host's `login` hook defensively: a throw is treated as a denial (uniform failure) and
209
- * warn-logged once, so a buggy hook never 500s the endpoint into a stack-trace leak nor floods
210
- * the logs on repeated bad attempts.
232
+ * Run a host hook (`login` or `session`) defensively: a throw is treated as a denial (uniform
233
+ * failure) and warn-logged once PER KIND, so a buggy hook never 500s the endpoint into a
234
+ * stack-trace leak nor floods the logs on repeated bad attempts.
211
235
  */
212
- private runLoginHook;
236
+ private runHook;
213
237
  }
214
238
 
215
239
  interface DurableDashboardOptions {
@@ -253,13 +277,36 @@ interface DurableDashboardOptions {
253
277
  imports?: DynamicModule['imports'];
254
278
  /**
255
279
  * Gate the console (the SPA at `basePath` AND its JSON API at `apiBasePath`) behind a built-in,
256
- * cookie-session login — no infra, no changes to the bundled React SPA. Unlike
257
- * `@dudousxd/nestjs-telescope`'s `dashboardAuth` (a client-rendered login screen inside its SPA),
258
- * the durable dashboard's login page is small, dependency-free, server-rendered HTML served
259
- * directly by `DurableAuthController` at `GET <basePath>/login` — the SPA bundle itself never
260
- * changes. A missing/invalid/expired session redirects a page-level request (a full-page
261
- * navigation to `basePath`) to that login page with `?returnTo=<original url>`; an API request
262
- * (`apiBasePath`, fetched by the SPA's own JS) gets a plain `401` instead.
280
+ * signed session cookie — no infra required. Two ways to mint it:
281
+ *
282
+ * - **Mode A (`session`, recommended)**: your host app already has its own auth (SSO/OIDC/
283
+ * whatever) — the host frontend, carrying that auth, POSTs to `<basePath>/session`, your
284
+ * `session` hook validates the raw request and returns the session user, and this library mints
285
+ * its cookie from that. No credential this library understands ever exists; you keep your own
286
+ * identity provider as the source of truth. A page-level request with no valid session (and only
287
+ * Mode A configured) gets a small server-rendered instruction page telling the visitor to sign in
288
+ * through your host app, since there is no login page to redirect to.
289
+ * - **Mode B (`login`, standalone fallback)**: no host frontend/IdP to lean on — the durable
290
+ * dashboard serves its own small, dependency-free, server-rendered login page (`GET
291
+ * <basePath>/login`, handled by `DurableAuthController`) and your `login` hook validates
292
+ * submitted username/password. A missing/invalid/expired session redirects a page-level request
293
+ * to that login page with `?returnTo=<original url>`.
294
+ *
295
+ * Either mode can be used alone, or both together (e.g. Mode A for normal use, Mode B as a
296
+ * break-glass fallback) — at least one is required, or `forRoot`/`forRootAsync` throws at boot
297
+ * (an un-mintable gate is a boot error, not a silently-open or silently-stuck console). An API
298
+ * request (`apiBasePath`, fetched by the SPA's own JS) always gets a plain `401` on a missing
299
+ * session, regardless of mode — the caller reads the status code, not HTML.
300
+ *
301
+ * A third, optional hook — **`revalidate`** — re-checks an already-minted session rather than
302
+ * minting one: once a session cookie is past 50% of its TTL, the guard slides it forward with a
303
+ * fresh one, and if `revalidate` is set it's consulted first, so a deactivated or demoted
304
+ * operator loses access instead of riding a self-renewing cookie indefinitely. It only runs on
305
+ * that renewal path, not on every request, so revocation has **latency, not immediacy**: an
306
+ * operator revoked right after a renewal keeps their existing cookie — and therefore console
307
+ * access — for up to `ttl/2` (4 hours at the default 8h TTL) before the next renewal check catches
308
+ * it. Do not rely on `revalidate` for a same-second cutoff; front the mount with your own guard
309
+ * (see {@link guards}) if you need that.
263
310
  *
264
311
  * Auth here is mount-level and role-agnostic: this option (and the guards/controller it wires up)
265
312
  * has no notion of control-plane vs tenant role (see the durable dashboard's topology note in
@@ -271,18 +318,19 @@ interface DurableDashboardOptions {
271
318
  * the console open (today's behavior, unchanged byte-for-byte) — front it with `guards`, a global
272
319
  * guard, or a reverse proxy instead.
273
320
  *
274
- * See {@link DashboardAuthOptions} for the `secret`/`ttl`/`login` shape, and `forRootAsync` /
275
- * {@link DurableDashboardAsyncOptions.useDashboardAuth} when the `login` hook needs DI (e.g. an
276
- * `EntityManager` to look up an admin user).
321
+ * See {@link DashboardAuthOptions} for the `secret`/`ttl`/`session`/`login`/`revalidate` shape,
322
+ * and `forRootAsync` / {@link DurableDashboardAsyncOptions.useDashboardAuth} when a hook needs DI
323
+ * (e.g. an `EntityManager` to look up an admin user).
277
324
  */
278
325
  dashboardAuth?: DashboardAuthOptions;
279
326
  }
280
327
  /**
281
328
  * Async variant of {@link DurableDashboardOptions}. The mount paths and `guards` stay static (the
282
329
  * router and guard metadata are bound at module-DEFINITION time — the same constraint `forRoot`
283
- * already has), but `dashboardAuth` is built by an injected factory, so its `login` hook can reach
284
- * your DB/services. Returning `undefined` from `useDashboardAuth` leaves the console open, exactly
285
- * like omitting `dashboardAuth` from `forRoot`.
330
+ * already has), but `dashboardAuth` is built by an injected factory, so its `session`/`login` hooks
331
+ * can reach your DB/services — e.g. Mode A's `session` hook calling out to the same auth service
332
+ * your host app already uses to validate the raw request. Returning `undefined` from
333
+ * `useDashboardAuth` leaves the console open, exactly like omitting `dashboardAuth` from `forRoot`.
286
334
  */
287
335
  interface DurableDashboardAsyncOptions {
288
336
  basePath?: string;
@@ -332,10 +380,12 @@ declare class DurableDashboardModule {
332
380
  /**
333
381
  * Gates `DurableUiController` (the SPA shell + assets, a full-page browser navigation) on a valid
334
382
  * `dashboardAuth` session cookie. A no-op (always `true`) when `dashboardAuth` was not configured
335
- * — see `DurableDashboardModule.forRoot`'s `dashboardAuth` doc. Missing/invalid/expired session =>
336
- * a `302` to the built-in login page carrying `?returnTo=<original url>`, via
337
- * `DashboardLoginRedirectException` (see that file for why a guard can't just call
338
- * `response.redirect()` and return `false`).
383
+ * — see `DurableDashboardModule.forRoot`'s `dashboardAuth` doc. Missing/invalid/expired session,
384
+ * when Mode B (`login`) is configured, gets a `302` to the built-in login page carrying
385
+ * `?returnTo=<original url>`, via `DashboardLoginRedirectException` (see that file for why a guard
386
+ * can't just call `response.redirect()` and return `false`). When only Mode A (`session`) is
387
+ * configured there is no login page to redirect to — the host mints the session itself — so it
388
+ * instead throws `DashboardSessionRequiredException`, rendering a small instruction page.
339
389
  *
340
390
  * Auth is mount-level and role-agnostic: this guard has no notion of control-plane vs tenant
341
391
  * (see the durable dashboard's topology note in `dashboard.service.ts`) — it only ever answers
@@ -345,7 +395,18 @@ declare class DurableUiSessionGuard implements CanActivate {
345
395
  private readonly auth;
346
396
  private readonly basePath;
347
397
  constructor(auth: ResolvedDashboardAuth | null, basePath: string);
348
- canActivate(context: ExecutionContext): boolean;
398
+ canActivate(context: ExecutionContext): Promise<boolean>;
399
+ /**
400
+ * A revoked session (the host's `revalidate` hook says no) is denied exactly like an absent
401
+ * one — by design, there is nothing left to distinguish "never had a session" from "had one,
402
+ * then lost it" once the cookie is cleared, so both get the same Mode-aware treatment.
403
+ *
404
+ * Takes `auth` explicitly rather than reading `this.auth`: `canActivate`'s `!this.auth` early
405
+ * return narrows the field within that method only — TS doesn't carry it across a method call
406
+ * — so callers pass the already-narrowed value instead of this method re-deriving (or
407
+ * asserting away) the nullability.
408
+ */
409
+ private denyUnauthenticated;
349
410
  }
350
411
  /**
351
412
  * Gates `DurableApiController` (the JSON API the SPA fetches) on a valid `dashboardAuth` session
@@ -358,7 +419,14 @@ declare class DurableUiSessionGuard implements CanActivate {
358
419
  declare class DurableApiSessionGuard implements CanActivate {
359
420
  private readonly auth;
360
421
  constructor(auth: ResolvedDashboardAuth | null);
361
- canActivate(context: ExecutionContext): boolean;
422
+ canActivate(context: ExecutionContext): Promise<boolean>;
423
+ /**
424
+ * A bare 401 body carrying `{ auth: { modes } }` — mirrors `@dudousxd/nestjs-telescope`'s
425
+ * dashboardAuth 401. The API is fetched (never navigated), so this is the ONLY way the console
426
+ * SPA (`durable-client.ts`) learns which auth surface to send the operator to on a mid-session
427
+ * 401; `DurableUiSessionGuard`'s redirect/session-required exceptions above don't reach it.
428
+ */
429
+ private unauthorized;
362
430
  }
363
431
 
364
432
  /** DI token carrying the UI mount base (e.g. `/durable`). */
@@ -370,11 +438,13 @@ declare const DASHBOARD_API_PATH: unique symbol;
370
438
  * The path comes from `RouterModule` (set by `DurableDashboardModule.forRoot({ basePath })`), so the
371
439
  * controller routes are relative.
372
440
  *
373
- * `@UseFilters(DashboardLoginRedirectFilter)` is permanent (present regardless of whether
374
- * `dashboardAuth` is configured): it only ever activates for a `DashboardLoginRedirectException`,
375
- * which `DurableUiSessionGuard` only throws when it is ALSO stamped on this controller (see
376
- * `DurableDashboardModule.forRoot`) — so leaving it decorated here is inert, not a behavior change,
377
- * when `dashboardAuth` is absent.
441
+ * `@UseFilters(DashboardLoginRedirectFilter, DashboardSessionRequiredFilter)` is permanent (present
442
+ * regardless of whether `dashboardAuth` is configured): each only ever activates for its own
443
+ * exception type, which `DurableUiSessionGuard` only throws when it is ALSO stamped on this
444
+ * controller (see `DurableDashboardModule.forRoot`) — so leaving them decorated here is inert, not
445
+ * a behavior change, when `dashboardAuth` is absent. The guard picks between the two exceptions
446
+ * based on which mode is configured (Mode B => redirect to the login page; Mode A only => render
447
+ * the session-required instruction page in place, since there is no login page to redirect to).
378
448
  */
379
449
  declare class DurableUiController {
380
450
  private readonly basePath;
@@ -12,32 +12,55 @@ interface DashboardSessionUser {
12
12
  roles?: string[];
13
13
  }
14
14
 
15
- /** Host hook validating submitted credentials from the built-in login page. */
15
+ /** Host hook for Mode A — validates the host's own auth on the raw request. */
16
+ type SessionHook = (request: unknown) => Promise<DashboardSessionUser | null> | DashboardSessionUser | null;
17
+ /** Host hook for Mode B — validates submitted credentials from the built-in login page. */
16
18
  type LoginHook = (username: string, password: string) => Promise<DashboardSessionUser | null> | DashboardSessionUser | null;
19
+ /**
20
+ * Re-checks a LIVE session when the cookie is slid forward. Runs at most once per `ttl/2` per
21
+ * session, so a DB round-trip here is cheap — this holds even under concurrent traffic: N parallel
22
+ * requests carrying the same past-half-life cookie share ONE call (see `revalidateOnce` in
23
+ * `session-cookie-io.ts`), not N. Return `false` to revoke (the cookie is cleared and the request
24
+ * denied). Distinct from `session`: that hook reads the host's auth off a fresh request, which a
25
+ * console XHR does not carry — this one receives the already-minted session.
26
+ */
27
+ type RevalidateHook = (session: DashboardSessionUser) => Promise<boolean> | boolean;
28
+ type AuthMode = 'session' | 'login';
17
29
  /**
18
30
  * Author-facing `dashboardAuth` option on `DurableDashboardModule.forRoot`/`forRootAsync`. Gates
19
31
  * BOTH the SPA (a full-page navigation redirected to a server-rendered login page) and the JSON
20
32
  * API (a plain `401`) behind a signed session cookie, mirroring `@dudousxd/nestjs-telescope`'s
21
- * `dashboardAuth` mechanism (same HMAC-SHA256 cookie, same fail-closed validation). Unlike
22
- * Telescope's dashboard — a client-rendered SPA that can grow its own login screen — the durable
23
- * dashboard's bundled React app stays untouched: the login screen here is a small, dependency-free
24
- * server-rendered HTML page (`GET <basePath>/login`), so gating the SPA shell itself with a real
25
- * redirect doesn't require rebuilding or extending the Vite bundle.
33
+ * `dashboardAuth` mechanism (same HMAC-SHA256 cookie, same fail-closed validation). Two ways to
34
+ * mint that cookie: Mode A (`session`) — the host frontend, already carrying its own auth, POSTs
35
+ * to `<basePath>/session` and the host hook decides; or Mode B (`login`) — the built-in,
36
+ * dependency-free server-rendered login page (`GET <basePath>/login`). At least one of the two is
37
+ * required so an un-mintable gate is a boot error, not a silently-open (or silently-stuck) console.
26
38
  */
27
39
  interface DashboardAuthOptions {
28
40
  /** REQUIRED HMAC-SHA256 signing key. Missing/empty => boot error (fail closed). */
29
41
  secret: string;
30
42
  /** Cookie TTL as a duration string (`'8h'`, `'30m'`, `'7d'`). Default `'8h'`. */
31
43
  ttl?: string;
32
- /** Validates submitted username/password; return the session user, or `null` to deny. Thrown
33
- * errors are treated as a denial (logged once, never surfaced to the client). */
34
- login: LoginHook;
44
+ /** Mode A: validates the host's own auth on the raw request POSTed to `<basePath>/session`;
45
+ * return the session user, or `null` to deny. Thrown errors are treated as a denial (logged
46
+ * once, never surfaced to the client). */
47
+ session?: SessionHook;
48
+ /** Mode B: validates submitted username/password from the built-in login page; return the
49
+ * session user, or `null` to deny. Thrown errors are treated as a denial (logged once, never
50
+ * surfaced to the client). */
51
+ login?: LoginHook;
52
+ /** Re-checks a live session on sliding renewal; see `RevalidateHook`. Not a mode — it cannot
53
+ * mint a session, only revoke one already minted by `session`/`login`. */
54
+ revalidate?: RevalidateHook;
35
55
  }
36
56
  /** Resolved, validated `dashboardAuth` config shared by the guards, auth controller, and login page. */
37
57
  interface ResolvedDashboardAuth {
38
58
  secret: string;
39
59
  ttlMs: number;
40
- login: LoginHook;
60
+ modes: AuthMode[];
61
+ session?: SessionHook;
62
+ login?: LoginHook;
63
+ revalidate?: RevalidateHook;
41
64
  }
42
65
 
43
66
  /**
@@ -195,21 +218,22 @@ declare class DurableAuthController {
195
218
  private readonly auth;
196
219
  private readonly basePath;
197
220
  private readonly logger;
198
- /** Warn once so a throwing `login` hook doesn't spam logs on every failed attempt. */
199
- private warnedOnHookThrow;
221
+ /** Warn once per hook kind so a throwing hook doesn't spam logs on every failed attempt. */
222
+ private readonly warnedHooks;
200
223
  constructor(auth: ResolvedDashboardAuth | null, basePath: string);
201
224
  loginPage(): string;
202
225
  login(body: LoginBody, request: unknown, response: unknown): Promise<{
203
226
  redirectTo: string;
204
227
  }>;
228
+ session(request: unknown, response: unknown): Promise<void>;
205
229
  logout(request: unknown, response: unknown): void;
206
230
  private requireAuth;
207
231
  /**
208
- * Run the host's `login` hook defensively: a throw is treated as a denial (uniform failure) and
209
- * warn-logged once, so a buggy hook never 500s the endpoint into a stack-trace leak nor floods
210
- * the logs on repeated bad attempts.
232
+ * Run a host hook (`login` or `session`) defensively: a throw is treated as a denial (uniform
233
+ * failure) and warn-logged once PER KIND, so a buggy hook never 500s the endpoint into a
234
+ * stack-trace leak nor floods the logs on repeated bad attempts.
211
235
  */
212
- private runLoginHook;
236
+ private runHook;
213
237
  }
214
238
 
215
239
  interface DurableDashboardOptions {
@@ -253,13 +277,36 @@ interface DurableDashboardOptions {
253
277
  imports?: DynamicModule['imports'];
254
278
  /**
255
279
  * Gate the console (the SPA at `basePath` AND its JSON API at `apiBasePath`) behind a built-in,
256
- * cookie-session login — no infra, no changes to the bundled React SPA. Unlike
257
- * `@dudousxd/nestjs-telescope`'s `dashboardAuth` (a client-rendered login screen inside its SPA),
258
- * the durable dashboard's login page is small, dependency-free, server-rendered HTML served
259
- * directly by `DurableAuthController` at `GET <basePath>/login` — the SPA bundle itself never
260
- * changes. A missing/invalid/expired session redirects a page-level request (a full-page
261
- * navigation to `basePath`) to that login page with `?returnTo=<original url>`; an API request
262
- * (`apiBasePath`, fetched by the SPA's own JS) gets a plain `401` instead.
280
+ * signed session cookie — no infra required. Two ways to mint it:
281
+ *
282
+ * - **Mode A (`session`, recommended)**: your host app already has its own auth (SSO/OIDC/
283
+ * whatever) — the host frontend, carrying that auth, POSTs to `<basePath>/session`, your
284
+ * `session` hook validates the raw request and returns the session user, and this library mints
285
+ * its cookie from that. No credential this library understands ever exists; you keep your own
286
+ * identity provider as the source of truth. A page-level request with no valid session (and only
287
+ * Mode A configured) gets a small server-rendered instruction page telling the visitor to sign in
288
+ * through your host app, since there is no login page to redirect to.
289
+ * - **Mode B (`login`, standalone fallback)**: no host frontend/IdP to lean on — the durable
290
+ * dashboard serves its own small, dependency-free, server-rendered login page (`GET
291
+ * <basePath>/login`, handled by `DurableAuthController`) and your `login` hook validates
292
+ * submitted username/password. A missing/invalid/expired session redirects a page-level request
293
+ * to that login page with `?returnTo=<original url>`.
294
+ *
295
+ * Either mode can be used alone, or both together (e.g. Mode A for normal use, Mode B as a
296
+ * break-glass fallback) — at least one is required, or `forRoot`/`forRootAsync` throws at boot
297
+ * (an un-mintable gate is a boot error, not a silently-open or silently-stuck console). An API
298
+ * request (`apiBasePath`, fetched by the SPA's own JS) always gets a plain `401` on a missing
299
+ * session, regardless of mode — the caller reads the status code, not HTML.
300
+ *
301
+ * A third, optional hook — **`revalidate`** — re-checks an already-minted session rather than
302
+ * minting one: once a session cookie is past 50% of its TTL, the guard slides it forward with a
303
+ * fresh one, and if `revalidate` is set it's consulted first, so a deactivated or demoted
304
+ * operator loses access instead of riding a self-renewing cookie indefinitely. It only runs on
305
+ * that renewal path, not on every request, so revocation has **latency, not immediacy**: an
306
+ * operator revoked right after a renewal keeps their existing cookie — and therefore console
307
+ * access — for up to `ttl/2` (4 hours at the default 8h TTL) before the next renewal check catches
308
+ * it. Do not rely on `revalidate` for a same-second cutoff; front the mount with your own guard
309
+ * (see {@link guards}) if you need that.
263
310
  *
264
311
  * Auth here is mount-level and role-agnostic: this option (and the guards/controller it wires up)
265
312
  * has no notion of control-plane vs tenant role (see the durable dashboard's topology note in
@@ -271,18 +318,19 @@ interface DurableDashboardOptions {
271
318
  * the console open (today's behavior, unchanged byte-for-byte) — front it with `guards`, a global
272
319
  * guard, or a reverse proxy instead.
273
320
  *
274
- * See {@link DashboardAuthOptions} for the `secret`/`ttl`/`login` shape, and `forRootAsync` /
275
- * {@link DurableDashboardAsyncOptions.useDashboardAuth} when the `login` hook needs DI (e.g. an
276
- * `EntityManager` to look up an admin user).
321
+ * See {@link DashboardAuthOptions} for the `secret`/`ttl`/`session`/`login`/`revalidate` shape,
322
+ * and `forRootAsync` / {@link DurableDashboardAsyncOptions.useDashboardAuth} when a hook needs DI
323
+ * (e.g. an `EntityManager` to look up an admin user).
277
324
  */
278
325
  dashboardAuth?: DashboardAuthOptions;
279
326
  }
280
327
  /**
281
328
  * Async variant of {@link DurableDashboardOptions}. The mount paths and `guards` stay static (the
282
329
  * router and guard metadata are bound at module-DEFINITION time — the same constraint `forRoot`
283
- * already has), but `dashboardAuth` is built by an injected factory, so its `login` hook can reach
284
- * your DB/services. Returning `undefined` from `useDashboardAuth` leaves the console open, exactly
285
- * like omitting `dashboardAuth` from `forRoot`.
330
+ * already has), but `dashboardAuth` is built by an injected factory, so its `session`/`login` hooks
331
+ * can reach your DB/services — e.g. Mode A's `session` hook calling out to the same auth service
332
+ * your host app already uses to validate the raw request. Returning `undefined` from
333
+ * `useDashboardAuth` leaves the console open, exactly like omitting `dashboardAuth` from `forRoot`.
286
334
  */
287
335
  interface DurableDashboardAsyncOptions {
288
336
  basePath?: string;
@@ -332,10 +380,12 @@ declare class DurableDashboardModule {
332
380
  /**
333
381
  * Gates `DurableUiController` (the SPA shell + assets, a full-page browser navigation) on a valid
334
382
  * `dashboardAuth` session cookie. A no-op (always `true`) when `dashboardAuth` was not configured
335
- * — see `DurableDashboardModule.forRoot`'s `dashboardAuth` doc. Missing/invalid/expired session =>
336
- * a `302` to the built-in login page carrying `?returnTo=<original url>`, via
337
- * `DashboardLoginRedirectException` (see that file for why a guard can't just call
338
- * `response.redirect()` and return `false`).
383
+ * — see `DurableDashboardModule.forRoot`'s `dashboardAuth` doc. Missing/invalid/expired session,
384
+ * when Mode B (`login`) is configured, gets a `302` to the built-in login page carrying
385
+ * `?returnTo=<original url>`, via `DashboardLoginRedirectException` (see that file for why a guard
386
+ * can't just call `response.redirect()` and return `false`). When only Mode A (`session`) is
387
+ * configured there is no login page to redirect to — the host mints the session itself — so it
388
+ * instead throws `DashboardSessionRequiredException`, rendering a small instruction page.
339
389
  *
340
390
  * Auth is mount-level and role-agnostic: this guard has no notion of control-plane vs tenant
341
391
  * (see the durable dashboard's topology note in `dashboard.service.ts`) — it only ever answers
@@ -345,7 +395,18 @@ declare class DurableUiSessionGuard implements CanActivate {
345
395
  private readonly auth;
346
396
  private readonly basePath;
347
397
  constructor(auth: ResolvedDashboardAuth | null, basePath: string);
348
- canActivate(context: ExecutionContext): boolean;
398
+ canActivate(context: ExecutionContext): Promise<boolean>;
399
+ /**
400
+ * A revoked session (the host's `revalidate` hook says no) is denied exactly like an absent
401
+ * one — by design, there is nothing left to distinguish "never had a session" from "had one,
402
+ * then lost it" once the cookie is cleared, so both get the same Mode-aware treatment.
403
+ *
404
+ * Takes `auth` explicitly rather than reading `this.auth`: `canActivate`'s `!this.auth` early
405
+ * return narrows the field within that method only — TS doesn't carry it across a method call
406
+ * — so callers pass the already-narrowed value instead of this method re-deriving (or
407
+ * asserting away) the nullability.
408
+ */
409
+ private denyUnauthenticated;
349
410
  }
350
411
  /**
351
412
  * Gates `DurableApiController` (the JSON API the SPA fetches) on a valid `dashboardAuth` session
@@ -358,7 +419,14 @@ declare class DurableUiSessionGuard implements CanActivate {
358
419
  declare class DurableApiSessionGuard implements CanActivate {
359
420
  private readonly auth;
360
421
  constructor(auth: ResolvedDashboardAuth | null);
361
- canActivate(context: ExecutionContext): boolean;
422
+ canActivate(context: ExecutionContext): Promise<boolean>;
423
+ /**
424
+ * A bare 401 body carrying `{ auth: { modes } }` — mirrors `@dudousxd/nestjs-telescope`'s
425
+ * dashboardAuth 401. The API is fetched (never navigated), so this is the ONLY way the console
426
+ * SPA (`durable-client.ts`) learns which auth surface to send the operator to on a mid-session
427
+ * 401; `DurableUiSessionGuard`'s redirect/session-required exceptions above don't reach it.
428
+ */
429
+ private unauthorized;
362
430
  }
363
431
 
364
432
  /** DI token carrying the UI mount base (e.g. `/durable`). */
@@ -370,11 +438,13 @@ declare const DASHBOARD_API_PATH: unique symbol;
370
438
  * The path comes from `RouterModule` (set by `DurableDashboardModule.forRoot({ basePath })`), so the
371
439
  * controller routes are relative.
372
440
  *
373
- * `@UseFilters(DashboardLoginRedirectFilter)` is permanent (present regardless of whether
374
- * `dashboardAuth` is configured): it only ever activates for a `DashboardLoginRedirectException`,
375
- * which `DurableUiSessionGuard` only throws when it is ALSO stamped on this controller (see
376
- * `DurableDashboardModule.forRoot`) — so leaving it decorated here is inert, not a behavior change,
377
- * when `dashboardAuth` is absent.
441
+ * `@UseFilters(DashboardLoginRedirectFilter, DashboardSessionRequiredFilter)` is permanent (present
442
+ * regardless of whether `dashboardAuth` is configured): each only ever activates for its own
443
+ * exception type, which `DurableUiSessionGuard` only throws when it is ALSO stamped on this
444
+ * controller (see `DurableDashboardModule.forRoot`) — so leaving them decorated here is inert, not
445
+ * a behavior change, when `dashboardAuth` is absent. The guard picks between the two exceptions
446
+ * based on which mode is configured (Mode B => redirect to the login page; Mode A only => render
447
+ * the session-required instruction page in place, since there is no login page to redirect to).
378
448
  */
379
449
  declare class DurableUiController {
380
450
  private readonly basePath;