@dudousxd/nestjs-durable-dashboard 0.31.0 → 0.32.0

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.
@@ -3,7 +3,42 @@ import { RunGateway, StateStore, WorkflowEngine, RunQuery, RunListItem, DurableT
3
3
  export { RunDetail } from '@dudousxd/nestjs-durable-core';
4
4
  import * as rxjs from 'rxjs';
5
5
  import { Observable } from 'rxjs';
6
- import { DynamicModule, Type, CanActivate, StreamableFile } from '@nestjs/common';
6
+ import { DynamicModule, Type, CanActivate, Provider, InjectionToken, OptionalFactoryDependency, ExecutionContext, StreamableFile } from '@nestjs/common';
7
+
8
+ /** The session user the host `login` hook returns to mint a cookie. */
9
+ interface DashboardSessionUser {
10
+ id: string;
11
+ name?: string;
12
+ roles?: string[];
13
+ }
14
+
15
+ /** Host hook validating submitted credentials from the built-in login page. */
16
+ type LoginHook = (username: string, password: string) => Promise<DashboardSessionUser | null> | DashboardSessionUser | null;
17
+ /**
18
+ * Author-facing `dashboardAuth` option on `DurableDashboardModule.forRoot`/`forRootAsync`. Gates
19
+ * BOTH the SPA (a full-page navigation redirected to a server-rendered login page) and the JSON
20
+ * 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.
26
+ */
27
+ interface DashboardAuthOptions {
28
+ /** REQUIRED HMAC-SHA256 signing key. Missing/empty => boot error (fail closed). */
29
+ secret: string;
30
+ /** Cookie TTL as a duration string (`'8h'`, `'30m'`, `'7d'`). Default `'8h'`. */
31
+ 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;
35
+ }
36
+ /** Resolved, validated `dashboardAuth` config shared by the guards, auth controller, and login page. */
37
+ interface ResolvedDashboardAuth {
38
+ secret: string;
39
+ ttlMs: number;
40
+ login: LoginHook;
41
+ }
7
42
 
8
43
  /**
9
44
  * Read-model and actions backing the control-plane UI. Run-facing ops (list/detail/retry/cancel/
@@ -142,6 +177,41 @@ declare class DurableApiController {
142
177
  update(id: string, name: string, body: unknown): Promise<_dudousxd_nestjs_durable_core.UpdateResult>;
143
178
  }
144
179
 
180
+ interface LoginBody {
181
+ username?: unknown;
182
+ password?: unknown;
183
+ returnTo?: unknown;
184
+ }
185
+ /**
186
+ * Mints/clears the `dashboardAuth` session cookie and serves the built-in login page. Mounted
187
+ * under `basePath` alongside `DurableUiController` (see `DurableDashboardModule.forRoot`), but on
188
+ * a SEPARATE controller so it is never itself behind `DurableUiSessionGuard` — these endpoints
189
+ * CREATE the session that guard checks for. `@Optional()` on both injections: this controller is
190
+ * only registered when `dashboardAuth` might be configured (a static `forRoot({ dashboardAuth })`
191
+ * or any `forRootAsync`), but the resolved value can still be `null` at runtime for the async case
192
+ * — every handler below re-checks it and 404s rather than assuming it is set.
193
+ */
194
+ declare class DurableAuthController {
195
+ private readonly auth;
196
+ private readonly basePath;
197
+ private readonly logger;
198
+ /** Warn once so a throwing `login` hook doesn't spam logs on every failed attempt. */
199
+ private warnedOnHookThrow;
200
+ constructor(auth: ResolvedDashboardAuth | null, basePath: string);
201
+ loginPage(): string;
202
+ login(body: LoginBody, request: unknown, response: unknown): Promise<{
203
+ redirectTo: string;
204
+ }>;
205
+ logout(request: unknown, response: unknown): void;
206
+ private requireAuth;
207
+ /**
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.
211
+ */
212
+ private runLoginHook;
213
+ }
214
+
145
215
  interface DurableDashboardOptions {
146
216
  /**
147
217
  * Where the SPA (UI) is served. Default `/durable`. This is a page route — keep it out of an
@@ -181,6 +251,48 @@ interface DurableDashboardOptions {
181
251
  * host's own auth module, e.g. `imports: [AuthModule]` alongside `guards: [JwtAuthGuard]`.
182
252
  */
183
253
  imports?: DynamicModule['imports'];
254
+ /**
255
+ * 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.
263
+ *
264
+ * Auth here is mount-level and role-agnostic: this option (and the guards/controller it wires up)
265
+ * has no notion of control-plane vs tenant role (see the durable dashboard's topology note in
266
+ * `dashboard.service.ts`) — nothing role-specific changes based on who logs in.
267
+ *
268
+ * Composes with {@link guards}: when both are set, a request must pass the built-in session check
269
+ * AND every guard in `guards` — the SAME logical-AND semantics `@UseGuards` already gives multiple
270
+ * guards, since the built-in guard is simply prepended to whatever `guards` you pass. Omit to leave
271
+ * the console open (today's behavior, unchanged byte-for-byte) — front it with `guards`, a global
272
+ * guard, or a reverse proxy instead.
273
+ *
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).
277
+ */
278
+ dashboardAuth?: DashboardAuthOptions;
279
+ }
280
+ /**
281
+ * Async variant of {@link DurableDashboardOptions}. The mount paths and `guards` stay static (the
282
+ * 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`.
286
+ */
287
+ interface DurableDashboardAsyncOptions {
288
+ basePath?: string;
289
+ apiBasePath?: string;
290
+ guards?: Type<CanActivate>[];
291
+ imports?: DynamicModule['imports'];
292
+ /** Providers injected into `useDashboardAuth`, in order. Shared with a `guards` class's own deps. */
293
+ inject?: Array<InjectionToken | OptionalFactoryDependency>;
294
+ /** Build the `dashboardAuth` config from injected deps (or `undefined` to leave the console open). */
295
+ useDashboardAuth: (...deps: any[]) => DashboardAuthOptions | undefined | Promise<DashboardAuthOptions | undefined>;
184
296
  }
185
297
  /**
186
298
  * Holds the JSON API controller + its read service, mounted on its own path by `forRoot`. Dynamic:
@@ -194,17 +306,59 @@ declare class DurableApiModule {
194
306
  static register(options: {
195
307
  imports?: DynamicModule['imports'];
196
308
  guards?: Type<CanActivate>[];
309
+ /** Provider for `DASHBOARD_AUTH` — a `useValue` (`forRoot`) or a `useFactory` (`forRootAsync`). */
310
+ authProvider: Provider;
311
+ /** Whether the built-in `DurableApiSessionGuard` should be stamped on `DurableApiController`
312
+ * (true whenever `dashboardAuth` was set, or unconditionally for `forRootAsync`, where it's
313
+ * resolved at runtime and is a no-op if it ends up unconfigured). */
314
+ authMayBeConfigured: boolean;
197
315
  }): DynamicModule;
198
316
  }
199
317
  /**
200
318
  * Mounts the control plane: the bundled React SPA at `basePath` and its JSON API at `apiBasePath`
201
- * (default `<basePath>/api`). Import via `DurableDashboardModule.forRoot(...)` alongside
202
- * `DurableModule` (global), so it resolves the engine and store. Front the routes with the first-class
203
- * `guards` option (plus `imports` for the guards' own dependencies) — see
204
- * {@link DurableDashboardOptions.guards}.
319
+ * (default `<basePath>/api`). Import via `DurableDashboardModule.forRoot(...)` (or `forRootAsync`
320
+ * when `dashboardAuth`'s `login` hook needs injected services) alongside `DurableModule` (global),
321
+ * so it resolves the engine and store. Front the routes with the first-class `guards` option
322
+ * (plus `imports` for the guards' own dependencies) — see {@link DurableDashboardOptions.guards} —
323
+ * or with the built-in `dashboardAuth` cookie login, or both.
205
324
  */
206
325
  declare class DurableDashboardModule {
207
326
  static forRoot(options?: DurableDashboardOptions): DynamicModule;
327
+ static forRootAsync(options: DurableDashboardAsyncOptions): DynamicModule;
328
+ /** Shared wiring: static routing + the API module, with `dashboardAuth` supplied by `authProvider`. */
329
+ private static build;
330
+ }
331
+
332
+ /**
333
+ * Gates `DurableUiController` (the SPA shell + assets, a full-page browser navigation) on a valid
334
+ * `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`).
339
+ *
340
+ * Auth is mount-level and role-agnostic: this guard has no notion of control-plane vs tenant
341
+ * (see the durable dashboard's topology note in `dashboard.service.ts`) — it only ever answers
342
+ * "is there a valid dashboard session", nothing role-specific changes here.
343
+ */
344
+ declare class DurableUiSessionGuard implements CanActivate {
345
+ private readonly auth;
346
+ private readonly basePath;
347
+ constructor(auth: ResolvedDashboardAuth | null, basePath: string);
348
+ canActivate(context: ExecutionContext): boolean;
349
+ }
350
+ /**
351
+ * Gates `DurableApiController` (the JSON API the SPA fetches) on a valid `dashboardAuth` session
352
+ * cookie. A no-op (always `true`) when `dashboardAuth` was not configured. Missing/invalid/expired
353
+ * session => a plain `401` (the API is called via `fetch`, never a browser navigation, so a
354
+ * redirect would just fail the request anyway — the caller reads the status code, not HTML).
355
+ *
356
+ * Mount-level and role-agnostic, same as `DurableUiSessionGuard` above.
357
+ */
358
+ declare class DurableApiSessionGuard implements CanActivate {
359
+ private readonly auth;
360
+ constructor(auth: ResolvedDashboardAuth | null);
361
+ canActivate(context: ExecutionContext): boolean;
208
362
  }
209
363
 
210
364
  /** DI token carrying the UI mount base (e.g. `/durable`). */
@@ -215,6 +369,12 @@ declare const DASHBOARD_API_PATH: unique symbol;
215
369
  * Serves the bundled control-plane SPA at the configured base (+ hashed assets at `<base>/assets`).
216
370
  * The path comes from `RouterModule` (set by `DurableDashboardModule.forRoot({ basePath })`), so the
217
371
  * controller routes are relative.
372
+ *
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.
218
378
  */
219
379
  declare class DurableUiController {
220
380
  private readonly basePath;
@@ -225,4 +385,4 @@ declare class DurableUiController {
225
385
  asset(file: string): StreamableFile;
226
386
  }
227
387
 
228
- export { DASHBOARD_API_PATH, DASHBOARD_BASE_PATH, DashboardService, DurableApiController, DurableApiModule, DurableDashboardModule, type DurableDashboardOptions, DurableUiController };
388
+ export { DASHBOARD_API_PATH, DASHBOARD_BASE_PATH, type DashboardAuthOptions, DashboardService, type DashboardSessionUser, DurableApiController, DurableApiModule, DurableApiSessionGuard, DurableAuthController, type DurableDashboardAsyncOptions, DurableDashboardModule, type DurableDashboardOptions, DurableUiController, DurableUiSessionGuard };