@dudousxd/nestjs-durable-dashboard 0.30.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, 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
@@ -154,17 +224,141 @@ interface DurableDashboardOptions {
154
224
  * while the UI stays at `basePath`.
155
225
  */
156
226
  apiBasePath?: string;
227
+ /**
228
+ * Guard classes fronting BOTH dashboard controllers (the SPA at `basePath` and its JSON API at
229
+ * `apiBasePath`). Stamped onto each controller via `@nestjs/common`'s own `@UseGuards` metadata key
230
+ * — REPLACE semantics, so a second `forRoot(...)` call overwrites (not appends to) whatever a prior
231
+ * call stamped, same as re-applying `@UseGuards` by hand. Omit to leave the routes unguarded (the
232
+ * host fronts them another way, e.g. a global guard or reverse-proxy auth).
233
+ *
234
+ * A guard's own DEPENDENCIES resolve from this module's `imports` (see {@link imports}) — the
235
+ * dashboard module has no application context of its own to pull them from otherwise.
236
+ *
237
+ * Auth reality for the two mount points: `apiBasePath` is fetched by the SPA's own JS (an XHR/fetch
238
+ * from the same origin), so a guard reading a bearer/`Authorization` header there works exactly like
239
+ * any other API route. `basePath` (the UI shell, `DurableUiController.index()`) is a full-page
240
+ * navigation — the BROWSER issues that GET directly (typing the URL, a bookmark, a link click), so
241
+ * there is no custom header to read: only whatever ambient credential the browser sends by default
242
+ * (a cookie, or nothing) reaches the guard there. A guard that only checks an `Authorization` header
243
+ * will 403 the page shell while the API calls it makes right after would have passed — put session
244
+ * auth behind a cookie (or front the UI mount with your reverse proxy / SSO instead) if you need the
245
+ * shell itself gated.
246
+ */
247
+ guards?: Type<CanActivate>[];
248
+ /**
249
+ * Extra `imports` merged into the dashboard's dynamic module — the DI resolution path for a class
250
+ * passed to {@link guards} (or any other provider the controllers need reachable). Typically the
251
+ * host's own auth module, e.g. `imports: [AuthModule]` alongside `guards: [JwtAuthGuard]`.
252
+ */
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;
157
279
  }
158
- /** Holds the JSON API controller + its read service, mounted on its own path by `forRoot`. */
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>;
296
+ }
297
+ /**
298
+ * Holds the JSON API controller + its read service, mounted on its own path by `forRoot`. Dynamic:
299
+ * guards are DI-instantiated by the CONTROLLER's host module, so this module — not the outer wrapper
300
+ * — must carry the guard classes as providers plus the host's `imports` that resolve their
301
+ * dependencies. A static module here made `guards: [SomeGuardWithDeps]` fail at boot with "Nest can't
302
+ * resolve dependencies ... in the DurableApiModule context" even when the host passed the right
303
+ * `imports` to `forRoot` (mirrors `@dudousxd/nestjs-agent-dashboard`'s `AgentApiModule`).
304
+ */
159
305
  declare class DurableApiModule {
306
+ static register(options: {
307
+ imports?: DynamicModule['imports'];
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;
315
+ }): DynamicModule;
160
316
  }
161
317
  /**
162
318
  * Mounts the control plane: the bundled React SPA at `basePath` and its JSON API at `apiBasePath`
163
- * (default `<basePath>/api`). Import via `DurableDashboardModule.forRoot(...)` alongside
164
- * `DurableModule` (global), so it resolves the engine and store. Front the routes with a guard.
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.
165
324
  */
166
325
  declare class DurableDashboardModule {
167
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;
168
362
  }
169
363
 
170
364
  /** DI token carrying the UI mount base (e.g. `/durable`). */
@@ -175,6 +369,12 @@ declare const DASHBOARD_API_PATH: unique symbol;
175
369
  * Serves the bundled control-plane SPA at the configured base (+ hashed assets at `<base>/assets`).
176
370
  * The path comes from `RouterModule` (set by `DurableDashboardModule.forRoot({ basePath })`), so the
177
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.
178
378
  */
179
379
  declare class DurableUiController {
180
380
  private readonly basePath;
@@ -185,4 +385,4 @@ declare class DurableUiController {
185
385
  asset(file: string): StreamableFile;
186
386
  }
187
387
 
188
- 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 };