@dudousxd/nestjs-durable-dashboard 0.54.0 → 0.55.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.
Files changed (35) hide show
  1. package/dist/client/durable-client.d.ts +6 -1
  2. package/dist/client/durable-client.d.ts.map +1 -1
  3. package/dist/client/durable-client.js +9 -46
  4. package/dist/client/durable-client.js.map +1 -1
  5. package/dist/server/index.cjs +223 -1341
  6. package/dist/server/index.cjs.map +1 -1
  7. package/dist/server/index.d.cts +101 -388
  8. package/dist/server/index.d.ts +101 -388
  9. package/dist/server/index.js +208 -1320
  10. package/dist/server/index.js.map +1 -1
  11. package/dist/spa/assets/{bench-Dee6-QN_.js → bench-DCclNvcZ.js} +1 -1
  12. package/dist/spa/assets/index-B-wtX4GV.js +66 -0
  13. package/dist/spa/assets/{index-RjxbO7dU.js → index-ChjKRQZx.js} +1 -1
  14. package/dist/spa/assets/{preview-BscU9n0o.js → preview-CdsjvOrK.js} +1 -1
  15. package/dist/spa/bench.html +2 -2
  16. package/dist/spa/index.html +2 -2
  17. package/dist/spa/preview.html +2 -2
  18. package/package.json +6 -19
  19. package/dist/client/console-session.d.ts +0 -63
  20. package/dist/client/console-session.d.ts.map +0 -1
  21. package/dist/client/console-session.js +0 -107
  22. package/dist/client/console-session.js.map +0 -1
  23. package/dist/react/index.d.ts +0 -4
  24. package/dist/react/index.d.ts.map +0 -1
  25. package/dist/react/index.js +0 -14
  26. package/dist/react/index.js.map +0 -1
  27. package/dist/react/open-console-button.d.ts +0 -26
  28. package/dist/react/open-console-button.d.ts.map +0 -1
  29. package/dist/react/open-console-button.js +0 -26
  30. package/dist/react/open-console-button.js.map +0 -1
  31. package/dist/react/use-open-console.d.ts +0 -40
  32. package/dist/react/use-open-console.d.ts.map +0 -1
  33. package/dist/react/use-open-console.js +0 -77
  34. package/dist/react/use-open-console.js.map +0 -1
  35. package/dist/spa/assets/index-VXNPW483.js +0 -66
@@ -5,104 +5,7 @@ import * as rxjs from 'rxjs';
5
5
  import { Observable } from 'rxjs';
6
6
  import * as _dudousxd_nestjs_filter from '@dudousxd/nestjs-filter';
7
7
  import { FilterRunner } from '@dudousxd/nestjs-filter';
8
- import { CanActivate, ExecutionContext, Provider, Logger, DynamicModule, Type, InjectionToken, OptionalFactoryDependency, StreamableFile } from '@nestjs/common';
9
-
10
- /** The session user the host `login` hook returns to mint a cookie. */
11
- interface DashboardSessionUser {
12
- id: string;
13
- name?: string;
14
- roles?: string[];
15
- }
16
-
17
- /** Host hook for Mode A — validates the host's own auth on the raw request. */
18
- type SessionHook = (request: unknown) => Promise<DashboardSessionUser | null> | DashboardSessionUser | null;
19
- /** Host hook for Mode B — validates submitted credentials from the built-in login page. */
20
- type LoginHook = (username: string, password: string) => Promise<DashboardSessionUser | null> | DashboardSessionUser | null;
21
- /**
22
- * Re-checks a LIVE session when the cookie is slid forward. Runs at most once per `ttl/2` per
23
- * session, so a DB round-trip here is cheap — this holds even under concurrent traffic: N parallel
24
- * requests carrying the same past-half-life cookie share ONE call (see `revalidateOnce` in
25
- * `session-cookie-io.ts`), not N. Return `false` to revoke (the cookie is cleared and the request
26
- * denied). Distinct from `session`: that hook reads the host's auth off a fresh request, which a
27
- * console XHR does not carry — this one receives the already-minted session.
28
- */
29
- type RevalidateHook = (session: DashboardSessionUser) => Promise<boolean> | boolean;
30
- /** What `unauthenticatedPage` receives. An object (not positional args) so fields can be added later. */
31
- interface UnauthenticatedPageContext {
32
- /**
33
- * The platform-native request — Express' `Request`, Fastify's `FastifyRequest`. Typed `unknown`
34
- * for the same reason `SessionHook`'s is: this package refuses to depend on either platform.
35
- * Cast it to whatever your app actually runs on.
36
- */
37
- request: unknown;
38
- /**
39
- * The platform-native response. The hook OWNS it: it must write AND end it (`res.render(...)`,
40
- * `res.status(401).send(...)`, an Inertia render, ...). If the hook returns without writing, the
41
- * library falls back to its own built-in page rather than leaving the request hanging.
42
- */
43
- response: unknown;
44
- /** Where this console is mounted (e.g. `/durable`) — useful for a "back to the console" link. */
45
- basePath: string;
46
- }
47
- /**
48
- * Host-owned page for an unauthenticated full-page navigation to the console.
49
- *
50
- * The built-in page cannot know who hosts the console, so it can only say "open this console from
51
- * your application" in the abstract — it can't name the launcher, link to it, or look like the rest
52
- * of the host's product. This hook hands the whole response to the host instead: render through
53
- * your own template engine, your own Inertia page, whatever. The page is served AT the console's
54
- * own URL, so `/durable` stays `/durable`.
55
- *
56
- * Deliberately NOT a replacement for Mode B's built-in login form. A host that wants its own login
57
- * UI uses Mode A plus this hook, and posts to `<basePath>/session` from its own page — the mint
58
- * endpoint is the supported primitive for that.
59
- *
60
- * Fail-closed by construction: it only ever runs on a request that has ALREADY been denied. A hook
61
- * that throws, or that returns without writing, falls back to the built-in page — it cannot let
62
- * anyone in.
63
- */
64
- type UnauthenticatedPageHook = (context: UnauthenticatedPageContext) => void | Promise<void>;
65
- type AuthMode = 'session' | 'login';
66
- /**
67
- * Author-facing `dashboardAuth` option on `DurableDashboardModule.forRoot`/`forRootAsync`. Gates
68
- * BOTH the SPA (a full-page navigation redirected to a server-rendered login page) and the JSON
69
- * API (a plain `401`) behind a signed session cookie, mirroring `@dudousxd/nestjs-telescope`'s
70
- * `dashboardAuth` mechanism (same HMAC-SHA256 cookie, same fail-closed validation). Two ways to
71
- * mint that cookie: Mode A (`session`) — the host frontend, already carrying its own auth, POSTs
72
- * to `<basePath>/session` and the host hook decides; or Mode B (`login`) — the built-in,
73
- * dependency-free server-rendered login page (`GET <basePath>/login`). At least one of the two is
74
- * required so an un-mintable gate is a boot error, not a silently-open (or silently-stuck) console.
75
- */
76
- interface DashboardAuthOptions {
77
- /** REQUIRED HMAC-SHA256 signing key. Missing/empty => boot error (fail closed). */
78
- secret: string;
79
- /** Cookie TTL as a duration string (`'8h'`, `'30m'`, `'7d'`). Default `'8h'`. */
80
- ttl?: string;
81
- /** Mode A: validates the host's own auth on the raw request POSTed to `<basePath>/session`;
82
- * return the session user, or `null` to deny. Thrown errors are treated as a denial (logged
83
- * once, never surfaced to the client). */
84
- session?: SessionHook;
85
- /** Mode B: validates submitted username/password from the built-in login page; return the
86
- * session user, or `null` to deny. Thrown errors are treated as a denial (logged once, never
87
- * surfaced to the client). */
88
- login?: LoginHook;
89
- /** Re-checks a live session on sliding renewal; see `RevalidateHook`. Not a mode — it cannot
90
- * mint a session, only revoke one already minted by `session`/`login`. */
91
- revalidate?: RevalidateHook;
92
- /** Renders the host's own page for an unauthenticated navigation, in place of the built-in
93
- * "open this console from your application" card; see `UnauthenticatedPageHook`. */
94
- unauthenticatedPage?: UnauthenticatedPageHook;
95
- }
96
- /** Resolved, validated `dashboardAuth` config shared by the guards, auth controller, and login page. */
97
- interface ResolvedDashboardAuth {
98
- secret: string;
99
- ttlMs: number;
100
- modes: AuthMode[];
101
- session?: SessionHook;
102
- login?: LoginHook;
103
- revalidate?: RevalidateHook;
104
- unauthenticatedPage?: UnauthenticatedPageHook;
105
- }
8
+ import { DynamicModule, StreamableFile } from '@nestjs/common';
106
9
 
107
10
  /**
108
11
  * Read-model and actions backing the control-plane UI. Run-facing ops (list/detail/retry/cancel/
@@ -286,14 +189,20 @@ declare class RunQueryDraft {
286
189
  }
287
190
 
288
191
  /**
289
- * JSON API consumed by the control-plane SPA. Mounted at `apiBasePath` (set by RouterModule).
192
+ * JSON API consumed by the control-plane SPA. The mounted {@link DurableApiController} sits at
193
+ * `apiBasePath` (set by RouterModule).
194
+ *
195
+ * This base is what a host EXTENDS to serve the API from a controller of its own (with
196
+ * `forRoot({ routes: false, apiBasePath })`): `@Controller('<apiBasePath>') class MyConsoleApi
197
+ * extends DurableApiControllerBase {}`. It carries no guards and no `@nestjs/authorization` mark,
198
+ * so a subclass is protected only by what the host puts on it.
290
199
  *
291
200
  * The filter interceptor (what resolves `@ApplyFilter` into a query) is bound HERE rather than as
292
201
  * the app-wide `APP_INTERCEPTOR` that `FilterModule.forRoot()` registers: a library must not add a
293
202
  * global interceptor to its host, and on an app that already has one this would be the second copy,
294
203
  * running every filter in the app twice.
295
204
  */
296
- declare class DurableApiController {
205
+ declare class DurableApiControllerBase {
297
206
  private readonly dashboard;
298
207
  private readonly runner;
299
208
  constructor(dashboard: DashboardService, runner: FilterRunner);
@@ -426,103 +335,13 @@ declare class DurableApiController {
426
335
  /** Deliver a validated `ctx.onUpdate` to a run; the body is the update argument. */
427
336
  update(id: string, name: string, body: unknown): Promise<_dudousxd_nestjs_durable_core.UpdateResult>;
428
337
  }
429
-
430
- interface LoginBody {
431
- username?: unknown;
432
- password?: unknown;
433
- returnTo?: unknown;
434
- }
435
- /**
436
- * Mints/clears the `dashboardAuth` session cookie and serves the built-in login page. Mounted
437
- * under `basePath` alongside `DurableUiController` (see `DurableDashboardModule.forRoot`), but on
438
- * a SEPARATE controller so it is never itself behind `DurableUiSessionGuard` — these endpoints
439
- * CREATE the session that guard checks for. `@Optional()` on both injections: this controller is
440
- * only registered when `dashboardAuth` might be configured (a static `forRoot({ dashboardAuth })`
441
- * or any `forRootAsync`), but the resolved value can still be `null` at runtime for the async case
442
- * — every handler below re-checks it and 404s rather than assuming it is set.
443
- */
444
- declare class DurableAuthController {
445
- private readonly auth;
446
- private readonly basePath;
447
- private readonly logger;
448
- /** Warn once per hook kind so a throwing hook doesn't spam logs on every failed attempt. */
449
- private readonly warnedHooks;
450
- constructor(auth: ResolvedDashboardAuth | null, basePath: string);
451
- loginPage(): string;
452
- login(body: LoginBody, request: unknown, response: unknown): Promise<{
453
- redirectTo: string;
454
- }>;
455
- session(request: unknown, response: unknown): Promise<void>;
456
- logout(request: unknown, response: unknown): void;
457
- private requireAuth;
458
- /**
459
- * Run a host hook (`login` or `session`) defensively: a throw is treated as a denial (uniform
460
- * failure) and warn-logged once PER KIND, so a buggy hook never 500s the endpoint into a
461
- * stack-trace leak nor floods the logs on repeated bad attempts.
462
- */
463
- private runHook;
464
- }
465
-
466
- /** The env var the token gate reads when no `token` option is given. */
467
- declare const DURABLE_DASHBOARD_TOKEN_ENV = "DURABLE_DASHBOARD_TOKEN";
468
- /** DI token carrying the resolved {@link DashboardGate} (one per dashboard mount). */
469
- declare const DASHBOARD_GATE: unique symbol;
470
- /**
471
- * What the module knows about how the console is protected, captured at `forRoot`/`forRootAsync`
472
- * time. Everything that can change between module definition and boot (env vars, the async
473
- * `dashboardAuth` factory) is read later, in {@link resolveDashboardGate}.
474
- */
475
- interface DashboardGateOptions {
476
- /** Host `guards` were passed (non-empty). */
477
- hasGuards: boolean;
478
- /** `allowUnauthenticated` as passed. */
479
- allowUnauthenticated?: boolean;
480
- /** `token` as passed (else `DURABLE_DASHBOARD_TOKEN` is read at boot). */
481
- token?: string;
482
- }
483
- /**
484
- * How the gate treats every console request:
485
- *
486
- * - `delegated`: `guards` or `dashboardAuth` protect the console; the gate passes everything.
487
- * - `open`: `allowUnauthenticated: true`; the gate passes everything (warned at boot).
488
- * - `token`: a request must carry the token (`Authorization: Bearer`, `x-durable-token`, or
489
- * `?token=` on GET/HEAD).
490
- * - `unconfigured`: nothing was configured outside production; the gate passes everything
491
- * (warned at boot, because production will deny).
492
- * - `deny`: nothing was configured and `NODE_ENV=production`; every request is refused.
493
- */
494
- type DashboardGateMode = 'delegated' | 'open' | 'token' | 'unconfigured' | 'deny';
495
- interface DashboardGate {
496
- mode: DashboardGateMode;
497
- /** Set only when `mode === 'token'`. */
498
- token?: string;
499
- }
500
- /**
501
- * Decide the gate mode. Precedence: `guards`/`dashboardAuth` (they are the gate) > a token > the
502
- * `allowUnauthenticated` opt-out > the environment default. A token beats `allowUnauthenticated`
503
- * because a configured secret is never silently ignored.
504
- */
505
- declare function resolveDashboardGate(options: DashboardGateOptions, auth: ResolvedDashboardAuth | null): DashboardGate;
506
- /** Log the one boot line the resolved gate calls for (nothing for a properly configured console). */
507
- declare function reportDashboardGate(gate: DashboardGate, options: DashboardGateOptions, logger?: Pick<Logger, 'warn' | 'error'>): void;
508
- /** The `DASHBOARD_GATE` provider: resolves once, after `DASHBOARD_AUTH`, and logs once. */
509
- declare function dashboardGateProvider(options: DashboardGateOptions): Provider;
510
338
  /**
511
- * Read the token from `Authorization: Bearer <token>`, `x-durable-token`, or (GET/HEAD only) a
512
- * `token` query param. The query channel is read-only on purpose: a `?token=` URL ends up in access
513
- * logs, `Referer` headers and browser history, and must never authorize a mutation.
339
+ * The JSON API controller `DurableDashboardModule.forRoot(...)` mounts at `apiBasePath`. The module
340
+ * puts the `@nestjs/authorization` open mark on THIS class (and `decorators` on a per-registration
341
+ * subclass of it), never on {@link DurableApiControllerBase}, so a host's own subclass of the base
342
+ * inherits none of them.
514
343
  */
515
- declare function readDashboardToken(request: unknown): string | undefined;
516
- /**
517
- * The fail-closed default gate, stamped on `DurableUiController` and `DurableApiController` (after
518
- * the built-in session guard, before host `guards`) whenever the module can't prove at definition
519
- * time that something else protects the console. It never gates `DurableAuthController`, so the
520
- * login/session/logout routes stay reachable. See {@link DashboardGateMode} for what each mode does.
521
- */
522
- declare class DurableDashboardGateGuard implements CanActivate {
523
- private readonly gate;
524
- constructor(gate: DashboardGate);
525
- canActivate(context: ExecutionContext): boolean;
344
+ declare class DurableApiController extends DurableApiControllerBase {
526
345
  }
527
346
 
528
347
  interface DurableDashboardOptions {
@@ -538,150 +357,76 @@ interface DurableDashboardOptions {
538
357
  */
539
358
  apiBasePath?: string;
540
359
  /**
541
- * Guard classes fronting BOTH dashboard controllers (the SPA at `basePath` and its JSON API at
542
- * `apiBasePath`). Stamped onto each controller via `@nestjs/common`'s own `@UseGuards` metadata key
543
- * — REPLACE semantics, so a second `forRoot(...)` call overwrites (not appends to) whatever a prior
544
- * call stamped, same as re-applying `@UseGuards` by hand. A non-empty `guards` counts as the
545
- * console's authentication, so the fail-closed default gate steps aside (see
546
- * {@link allowUnauthenticated}). If the host fronts the routes another way (a global guard,
547
- * reverse-proxy auth), say so with `allowUnauthenticated: true` instead.
548
- *
549
- * A guard's own DEPENDENCIES resolve from this module's `imports` (see {@link imports}) — the
550
- * dashboard module has no application context of its own to pull them from otherwise.
551
- *
552
- * Auth reality for the two mount points: `apiBasePath` is fetched by the SPA's own JS (an XHR/fetch
553
- * from the same origin), so a guard reading a bearer/`Authorization` header there works exactly like
554
- * any other API route. `basePath` (the UI shell, `DurableUiController.index()`) is a full-page
555
- * navigation — the BROWSER issues that GET directly (typing the URL, a bookmark, a link click), so
556
- * there is no custom header to read: only whatever ambient credential the browser sends by default
557
- * (a cookie, or nothing) reaches the guard there. A guard that only checks an `Authorization` header
558
- * will 403 the page shell while the API calls it makes right after would have passed — put session
559
- * auth behind a cookie (or front the UI mount with your reverse proxy / SSO instead) if you need the
560
- * shell itself gated.
561
- */
562
- guards?: Type<CanActivate>[];
563
- /**
564
- * Extra `imports` merged into the dashboard's dynamic module — the DI resolution path for a class
565
- * passed to {@link guards} (or any other provider the controllers need reachable). Typically the
566
- * host's own auth module, e.g. `imports: [AuthModule]` alongside `guards: [JwtAuthGuard]`.
360
+ * Extra `imports` merged into the dashboard's dynamic module: the DI resolution path for a guard
361
+ * passed through {@link decorators} (`UseGuards(...)`), whose dependencies resolve from the module
362
+ * that hosts the controller. Typically the host's own auth module, unless it is global.
567
363
  */
568
364
  imports?: DynamicModule['imports'];
569
365
  /**
570
- * Gate the console (the SPA at `basePath` AND its JSON API at `apiBasePath`) behind a built-in,
571
- * signed session cookie — no infra required. Two ways to mint it:
572
- *
573
- * - **Mode A (`session`, recommended)**: your host app already has its own auth (SSO/OIDC/
574
- * whatever) — the host frontend, carrying that auth, POSTs to `<basePath>/session`, your
575
- * `session` hook validates the raw request and returns the session user, and this library mints
576
- * its cookie from that. No credential this library understands ever exists; you keep your own
577
- * identity provider as the source of truth. A page-level request with no valid session (and only
578
- * Mode A configured) gets a small server-rendered instruction page telling the visitor to sign in
579
- * through your host app, since there is no login page to redirect to.
580
- * - **Mode B (`login`, standalone fallback)**: no host frontend/IdP to lean on — the durable
581
- * dashboard serves its own small, dependency-free, server-rendered login page (`GET
582
- * <basePath>/login`, handled by `DurableAuthController`) and your `login` hook validates
583
- * submitted username/password. A missing/invalid/expired session redirects a page-level request
584
- * to that login page with `?returnTo=<original url>`.
585
- *
586
- * Either mode can be used alone, or both together (e.g. Mode A for normal use, Mode B as a
587
- * break-glass fallback) — at least one is required, or `forRoot`/`forRootAsync` throws at boot
588
- * (an un-mintable gate is a boot error, not a silently-open or silently-stuck console). An API
589
- * request (`apiBasePath`, fetched by the SPA's own JS) always gets a plain `401` on a missing
590
- * session, regardless of mode — the caller reads the status code, not HTML.
366
+ * Class decorators applied to BOTH controllers this module mounts (the SPA and its JSON API), the
367
+ * way `@nestjs/mcp`'s own `decorators` option works. Use it to hand the console to the official NestJS tooling, e.g.
368
+ * `[UseGuards(AuthenticationGuard), Can(ConsolePolicy, 'view')]` with `@nestjs/authentication`
369
+ * and `@nestjs/authorization`.
591
370
  *
592
- * A third, optional hook — **`revalidate`** — re-checks an already-minted session rather than
593
- * minting one: once a session cookie is past 50% of its TTL, the guard slides it forward with a
594
- * fresh one, and if `revalidate` is set it's consulted first, so a deactivated or demoted
595
- * operator loses access instead of riding a self-renewing cookie indefinitely. It only runs on
596
- * that renewal path, not on every request, so revocation has **latency, not immediacy**: an
597
- * operator revoked right after a renewal keeps their existing cookie — and therefore console
598
- * access — for up to `ttl/2` (4 hours at the default 8h TTL) before the next renewal check catches
599
- * it. Do not rely on `revalidate` for a same-second cutoff; front the mount with your own guard
600
- * (see {@link guards}) if you need that.
371
+ * Each registration gets fresh subclasses of the controllers, so decorators never leak into
372
+ * another registration or onto the exported classes. A guard named in `UseGuards(...)` resolves
373
+ * its dependencies from {@link imports} (or a global module).
601
374
  *
602
- * Auth here is mount-level and role-agnostic: this option (and the guards/controller it wires up)
603
- * has no notion of control-plane vs tenant role (see the durable dashboard's topology note in
604
- * `dashboard.service.ts`) — nothing role-specific changes based on who logs in.
375
+ * A non-empty `decorators` is how a mounted console states its protection. With
376
+ * `NODE_ENV=production`, a mounted console with no (or an empty) `decorators` fails the boot; see
377
+ * {@link DurableDashboardModule}. The module cannot tell a `Can(...)` from a decorator that
378
+ * protects nothing, so passing one is your statement that the console is protected.
605
379
  *
606
- * Composes with {@link guards}: when both are set, a request must pass the built-in session check
607
- * AND every guard in `guards` — the SAME logical-AND semantics `@UseGuards` already gives multiple
608
- * guards, since the built-in guard is simply prepended to whatever `guards` you pass. Setting it
609
- * counts as the console's authentication (the fail-closed default gate steps aside). Omit it and
610
- * configure `guards`, a {@link token}, or {@link allowUnauthenticated} instead.
611
- *
612
- * See {@link DashboardAuthOptions} for the `secret`/`ttl`/`session`/`login`/`revalidate` shape,
613
- * and `forRootAsync` / {@link DurableDashboardAsyncOptions.useDashboardAuth} when a hook needs DI
614
- * (e.g. an `EntityManager` to look up an admin user).
380
+ * Default: none. Outside production the console is then served open, and the controllers are
381
+ * marked `Can.Anyone()` for `@nestjs/authorization`, whose global guard otherwise refuses every
382
+ * route that declares no `@Can()`. A `@Can()` passed here is still enforced.
615
383
  */
616
- dashboardAuth?: DashboardAuthOptions;
384
+ decorators?: ClassDecorator[];
617
385
  /**
618
- * Explicitly serve the console with NO authentication from this module. Use it only when
619
- * something outside the module already protects it: a global guard, a reverse proxy / SSO in
620
- * front of the mount, a private network. Logs a boot warning.
386
+ * `false` mounts NO controllers: the SPA and its JSON API are not registered,
387
+ * while every provider still is (the run read/action service, the run filter, the SPA's base
388
+ * paths). Serve the console from controllers of your own instead, protected with ordinary Nest
389
+ * decorators, by extending {@link DurableUiControllerBase} and {@link DurableApiControllerBase}:
621
390
  *
622
- * Without it, an unconfigured console (no `guards`, no `dashboardAuth`, no token) is DENIED when
623
- * `NODE_ENV=production` (every UI and API request answers `403`, and one boot error explains how
624
- * to configure it). Outside production it is still served open, with a boot warning. Ignored when
625
- * `guards`, `dashboardAuth` or a token is configured.
626
- */
627
- allowUnauthenticated?: boolean;
628
- /**
629
- * Bearer-token gate for the console. Defaults to `process.env.DURABLE_DASHBOARD_TOKEN` (read at
630
- * boot). Only applies when neither `guards` nor `dashboardAuth` is configured: every UI and API
631
- * request must then carry it as `Authorization: Bearer <token>`, an `x-durable-token` header, or
632
- * (GET/HEAD only) a `?token=` query param, compared in constant time; anything else gets `401`.
391
+ * ```ts
392
+ * @Controller('ops/durable') @UseGuards(AuthenticationGuard) @Can(ConsolePolicy, 'view')
393
+ * class ConsoleUi extends DurableUiControllerBase {}
394
+ * @Controller('ops/durable/api') @UseGuards(AuthenticationGuard) @Can(ConsolePolicy, 'view')
395
+ * class ConsoleApi extends DurableApiControllerBase {}
396
+ * // in the module that declares them:
397
+ * imports: [DurableDashboardModule.forRoot({ routes: false, basePath: '/ops/durable', apiBasePath: '/ops/durable/api' })]
398
+ * ```
399
+ *
400
+ * `basePath`/`apiBasePath` still matter here: they are where you mounted the two controllers, and
401
+ * the SPA is told to load its assets from `basePath` and fetch from `apiBasePath`.
402
+ *
403
+ * The production rule does not apply: this module mounts nothing, and the routes are yours,
404
+ * protected by what you put on them like any other route you write. The `*Base` classes carry no
405
+ * guards and no `Can.Anyone()` mark, so under `@nestjs/authorization`'s global guard a subclass
406
+ * without a `@Can()` is denied. `decorators` throws when combined with `routes: false` rather than
407
+ * being silently ignored.
633
408
  *
634
- * Meant for scripts and reverse proxies that inject the header: the bundled SPA does not attach
635
- * the token to its own asset and API requests. Browser operators want `dashboardAuth` or `guards`.
409
+ * Default `true`. Named `routes` rather than `path: false` because `basePath`/`apiBasePath` are
410
+ * strings that keep their meaning (where the console lives) whether or not this module mounts it.
636
411
  */
637
- token?: string;
638
- }
639
- /**
640
- * Async variant of {@link DurableDashboardOptions}. The mount paths and `guards` stay static (the
641
- * router and guard metadata are bound at module-DEFINITION time — the same constraint `forRoot`
642
- * already has), but `dashboardAuth` is built by an injected factory, so its `session`/`login` hooks
643
- * can reach your DB/services — e.g. Mode A's `session` hook calling out to the same auth service
644
- * your host app already uses to validate the raw request. Returning `undefined` from
645
- * `useDashboardAuth` is exactly like omitting `dashboardAuth` from `forRoot`: the console then
646
- * falls back to `guards` / the token / `allowUnauthenticated`, and is DENIED in production when
647
- * none of those is configured.
648
- */
649
- interface DurableDashboardAsyncOptions {
650
- basePath?: string;
651
- apiBasePath?: string;
652
- guards?: Type<CanActivate>[];
653
- imports?: DynamicModule['imports'];
654
- /** Providers injected into `useDashboardAuth`, in order. Shared with a `guards` class's own deps. */
655
- inject?: Array<InjectionToken | OptionalFactoryDependency>;
656
- /** Build the `dashboardAuth` config from injected deps (or `undefined` for no session auth). */
657
- useDashboardAuth: (...deps: any[]) => DashboardAuthOptions | undefined | Promise<DashboardAuthOptions | undefined>;
658
- /** See {@link DurableDashboardOptions.allowUnauthenticated}. */
659
- allowUnauthenticated?: boolean;
660
- /** See {@link DurableDashboardOptions.token}. */
661
- token?: string;
412
+ routes?: boolean;
662
413
  }
414
+ /** DI token of the boot-time check that refuses an unprotected console in production. */
415
+ declare const DASHBOARD_PROTECTION_CHECK: unique symbol;
416
+ /** The boot error for a mounted console with no `decorators` under `NODE_ENV=production`. */
417
+ declare const UNPROTECTED_CONSOLE_IN_PRODUCTION: string;
663
418
  /**
664
- * Holds the JSON API controller + its read service, mounted on its own path by `forRoot`. Dynamic:
665
- * guards are DI-instantiated by the CONTROLLER's host module, so this module — not the outer wrapper
666
- * — must carry the guard classes as providers plus the host's `imports` that resolve their
667
- * dependencies. A static module here made `guards: [SomeGuardWithDeps]` fail at boot with "Nest can't
668
- * resolve dependencies ... in the DurableApiModule context" even when the host passed the right
669
- * `imports` to `forRoot` (mirrors `@dudousxd/nestjs-agent-dashboard`'s `AgentApiModule`).
419
+ * Holds the JSON API controller + its read service, mounted on its own path by `forRoot`. Its own
420
+ * dynamic module because enhancers (a guard from `decorators`) DI-instantiate from their
421
+ * controller's host module, so this module, not the outer wrapper, must carry the host's `imports`.
670
422
  */
671
423
  declare class DurableApiModule {
672
424
  static register(options: {
673
425
  imports?: DynamicModule['imports'];
674
- guards?: Type<CanActivate>[];
675
- /** Provider for `DASHBOARD_AUTH` — a `useValue` (`forRoot`) or a `useFactory` (`forRootAsync`). */
676
- authProvider: Provider;
677
- /** Whether the built-in `DurableApiSessionGuard` should be stamped on `DurableApiController`
678
- * (true whenever `dashboardAuth` was set, or unconditionally for `forRootAsync`, where it's
679
- * resolved at runtime and is a no-op if it ends up unconfigured). */
680
- authMayBeConfigured: boolean;
681
- /** What the fail-closed gate needs; resolved into `DASHBOARD_GATE` at boot (logged once). */
682
- gate: DashboardGateOptions;
683
- /** Whether `DurableDashboardGateGuard` is stamped (false when guards/dashboardAuth are static). */
684
- stampGate: boolean;
426
+ /** `false`: register the providers only, no controller (see `routes`). */
427
+ routes?: boolean;
428
+ /** See {@link DurableDashboardOptions.decorators}. */
429
+ decorators?: ClassDecorator[];
685
430
  }): DynamicModule;
686
431
  }
687
432
  /**
@@ -691,66 +436,28 @@ declare class DurableApiModule {
691
436
  * so it resolves the engine and store. Front the routes with the first-class `guards` option
692
437
  * (plus `imports` for the guards' own dependencies) — see {@link DurableDashboardOptions.guards} —
693
438
  * or with the built-in `dashboardAuth` cookie login, or both.
694
- * With none of those (and no `token` / `allowUnauthenticated`), the console fails closed when
695
- * `NODE_ENV=production` — see {@link DurableDashboardOptions.allowUnauthenticated}.
439
+ * With none of those (and no `decorators` / `token` / `allowUnauthenticated`), the console fails
440
+ * closed when `NODE_ENV=production` — see {@link DurableDashboardOptions.allowUnauthenticated}.
441
+ * `routes: false` mounts nothing and leaves serving the console to the host's own controllers — see
442
+ * {@link DurableDashboardOptions.routes}.
696
443
  */
697
- declare class DurableDashboardModule {
698
- static forRoot(options?: DurableDashboardOptions): DynamicModule;
699
- static forRootAsync(options: DurableDashboardAsyncOptions): DynamicModule;
700
- /** Shared wiring: static routing + the API module, with `dashboardAuth` supplied by `authProvider`. */
701
- private static build;
702
- }
703
-
704
444
  /**
705
- * Gates `DurableUiController` (the SPA shell + assets, a full-page browser navigation) on a valid
706
- * `dashboardAuth` session cookie. A no-op (always `true`) when `dashboardAuth` was not configured
707
- * — see `DurableDashboardModule.forRoot`'s `dashboardAuth` doc. Missing/invalid/expired session,
708
- * when Mode B (`login`) is configured, gets a `302` to the built-in login page carrying
709
- * `?returnTo=<original url>`, via `DashboardLoginRedirectException` (see that file for why a guard
710
- * can't just call `response.redirect()` and return `false`). When only Mode A (`session`) is
711
- * configured there is no login page to redirect to — the host mints the session itself — so it
712
- * instead throws `DashboardSessionRequiredException`, rendering a small instruction page.
445
+ * Mounts the control plane: the bundled React SPA at `basePath` and its JSON API at `apiBasePath`
446
+ * (default `<basePath>/api`). Import via `DurableDashboardModule.forRoot(...)` alongside
447
+ * `DurableModule` (global), so it resolves the engine and store.
713
448
  *
714
- * Auth is mount-level and role-agnostic: this guard has no notion of control-plane vs tenant
715
- * (see the durable dashboard's topology note in `dashboard.service.ts`) — it only ever answers
716
- * "is there a valid dashboard session", nothing role-specific changes here.
717
- */
718
- declare class DurableUiSessionGuard implements CanActivate {
719
- private readonly auth;
720
- private readonly basePath;
721
- constructor(auth: ResolvedDashboardAuth | null, basePath: string);
722
- canActivate(context: ExecutionContext): Promise<boolean>;
723
- /**
724
- * A revoked session (the host's `revalidate` hook says no) is denied exactly like an absent
725
- * one — by design, there is nothing left to distinguish "never had a session" from "had one,
726
- * then lost it" once the cookie is cleared, so both get the same Mode-aware treatment.
727
- *
728
- * Takes `auth` explicitly rather than reading `this.auth`: `canActivate`'s `!this.auth` early
729
- * return narrows the field within that method only — TS doesn't carry it across a method call
730
- * — so callers pass the already-narrowed value instead of this method re-deriving (or
731
- * asserting away) the nullability.
732
- */
733
- private denyUnauthenticated;
734
- }
735
- /**
736
- * Gates `DurableApiController` (the JSON API the SPA fetches) on a valid `dashboardAuth` session
737
- * cookie. A no-op (always `true`) when `dashboardAuth` was not configured. Missing/invalid/expired
738
- * session => a plain `401` (the API is called via `fetch`, never a browser navigation, so a
739
- * redirect would just fail the request anyway — the caller reads the status code, not HTML).
449
+ * Protect the console with the first-party NestJS tools (`@nestjs/authentication`,
450
+ * `@nestjs/authorization`), one of two ways:
451
+ *
452
+ * 1. `decorators` on the mounted controllers (see {@link DurableDashboardOptions.decorators});
453
+ * 2. `routes: false`, and mount it yourself by extending `DurableUiControllerBase` /
454
+ * `DurableApiControllerBase` (see {@link DurableDashboardOptions.routes}).
740
455
  *
741
- * Mount-level and role-agnostic, same as `DurableUiSessionGuard` above.
456
+ * With `NODE_ENV=production`, a mounted console with no `decorators` fails the boot with
457
+ * {@link UNPROTECTED_CONSOLE_IN_PRODUCTION}. Outside production it is served open, with a warning.
742
458
  */
743
- declare class DurableApiSessionGuard implements CanActivate {
744
- private readonly auth;
745
- constructor(auth: ResolvedDashboardAuth | null);
746
- canActivate(context: ExecutionContext): Promise<boolean>;
747
- /**
748
- * A bare 401 body carrying `{ auth: { modes } }` — mirrors `@dudousxd/nestjs-telescope`'s
749
- * dashboardAuth 401. The API is fetched (never navigated), so this is the ONLY way the console
750
- * SPA (`durable-client.ts`) learns which auth surface to send the operator to on a mid-session
751
- * 401; `DurableUiSessionGuard`'s redirect/session-required exceptions above don't reach it.
752
- */
753
- private unauthorized;
459
+ declare class DurableDashboardModule {
460
+ static forRoot(options?: DurableDashboardOptions): DynamicModule;
754
461
  }
755
462
 
756
463
  /** DI token carrying the UI mount base (e.g. `/durable`). */
@@ -759,18 +466,16 @@ declare const DASHBOARD_BASE_PATH: unique symbol;
759
466
  declare const DASHBOARD_API_PATH: unique symbol;
760
467
  /**
761
468
  * Serves the bundled control-plane SPA at the configured base (+ hashed assets at `<base>/assets`).
762
- * The path comes from `RouterModule` (set by `DurableDashboardModule.forRoot({ basePath })`), so the
763
- * controller routes are relative.
469
+ * The controller routes are relative: the mounted {@link DurableUiController} gets its path from
470
+ * `RouterModule` (set by `DurableDashboardModule.forRoot({ basePath })`).
764
471
  *
765
- * `@UseFilters(DashboardLoginRedirectFilter, DashboardSessionRequiredFilter)` is permanent (present
766
- * regardless of whether `dashboardAuth` is configured): each only ever activates for its own
767
- * exception type, which `DurableUiSessionGuard` only throws when it is ALSO stamped on this
768
- * controller (see `DurableDashboardModule.forRoot`) — so leaving them decorated here is inert, not
769
- * a behavior change, when `dashboardAuth` is absent. The guard picks between the two exceptions
770
- * based on which mode is configured (Mode B => redirect to the login page; Mode A only => render
771
- * the session-required instruction page in place, since there is no login page to redirect to).
472
+ * This base is what a host EXTENDS to serve the console from a controller of its own (with
473
+ * `forRoot({ routes: false, basePath })`): `@Controller('<basePath>') class MyConsole extends
474
+ * DurableUiControllerBase {}`, protected with the host's own decorators. It carries no guards and
475
+ * no `@nestjs/authorization` mark, so a subclass is protected only by what the host puts on it (and,
476
+ * under `AuthorizationModule`'s global guard, is denied until it declares a `@Can()`).
772
477
  */
773
- declare class DurableUiController {
478
+ declare class DurableUiControllerBase {
774
479
  private readonly basePath;
775
480
  private readonly apiBasePath;
776
481
  private readonly dir;
@@ -778,5 +483,13 @@ declare class DurableUiController {
778
483
  index(): string;
779
484
  asset(file: string): StreamableFile;
780
485
  }
486
+ /**
487
+ * The SPA controller `DurableDashboardModule.forRoot(...)` mounts at `basePath`. The module puts
488
+ * the `@nestjs/authorization` open mark on THIS class (and `decorators` on a per-registration
489
+ * subclass of it), never on {@link DurableUiControllerBase}, so a host's own subclass of the base
490
+ * inherits none of them.
491
+ */
492
+ declare class DurableUiController extends DurableUiControllerBase {
493
+ }
781
494
 
782
- export { DASHBOARD_API_PATH, DASHBOARD_BASE_PATH, DASHBOARD_GATE, DURABLE_DASHBOARD_TOKEN_ENV, type DashboardAuthOptions, type DashboardGate, type DashboardGateMode, type DashboardGateOptions, DashboardService, type DashboardSessionUser, DurableApiController, DurableApiModule, DurableApiSessionGuard, DurableAuthController, type DurableDashboardAsyncOptions, DurableDashboardGateGuard, DurableDashboardModule, type DurableDashboardOptions, DurableUiController, DurableUiSessionGuard, type UnauthenticatedPageContext, type UnauthenticatedPageHook, type WorkflowLifecyclePage, dashboardGateProvider, readDashboardToken, reportDashboardGate, resolveDashboardGate };
495
+ export { DASHBOARD_API_PATH, DASHBOARD_BASE_PATH, DASHBOARD_PROTECTION_CHECK, DashboardService, DurableApiController, DurableApiControllerBase, DurableApiModule, DurableDashboardModule, type DurableDashboardOptions, DurableUiController, DurableUiControllerBase, UNPROTECTED_CONSOLE_IN_PRODUCTION, type WorkflowLifecyclePage };