@dudousxd/nestjs-agent-dashboard 0.10.0 → 0.11.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.
@@ -203,16 +203,55 @@ type SessionHook = (request: unknown) => Promise<DashboardSessionUser | null> |
203
203
  type LoginHook = (username: string, password: string) => Promise<DashboardSessionUser | null> | DashboardSessionUser | null;
204
204
  /**
205
205
  * Re-checks a LIVE session when the cookie is slid forward. Runs at most once per `ttl/2` PER
206
- * COOKIE GENERATION, per in-flight request — the gate is on the inbound cookie's `iat`, so every
207
- * request still carrying a not-yet-renewed cookie invokes this hook, not just the first: a page
208
- * load that fires N parallel API calls can trigger up to N calls before the refreshed cookie lands
209
- * on the client. Bounded and cheap for typical console traffic, but not a per-session-lifetime
210
- * cap — a slow or expensive check should debounce/cache on its own. Return `false` to revoke (the
211
- * cookie is cleared and the request denied). Distinct from `session`: that hook reads the host's
212
- * auth off a fresh request, which a console XHR does not carry — this one receives the
213
- * already-minted session.
206
+ * COOKIE GENERATION — the gate is on the inbound cookie's `iat`, so every request still carrying a
207
+ * not-yet-renewed cookie would, on its own, invoke this hook again; `maybeRenewSession` de-dupes
208
+ * concurrent calls for the same session (`sub` + `iat`) into a single in-flight call, so a page
209
+ * load that fires N parallel API calls still costs exactly ONE host round-trip, not N. Still not a
210
+ * per-session-lifetime cap — this can run again every `ttl/2` as the session keeps sliding, so a
211
+ * slow or expensive check should still debounce/cache across renewal windows on its own. Return
212
+ * `false` to revoke (the cookie is cleared and the request denied). Distinct from `session`: that
213
+ * hook reads the host's auth off a fresh request, which a console XHR does not carry — this one
214
+ * receives the already-minted session.
214
215
  */
215
216
  type RevalidateHook = (session: DashboardSessionUser) => Promise<boolean> | boolean;
217
+ /** What `unauthenticatedPage` receives. An object (not positional args) so fields can be added later. */
218
+ interface UnauthenticatedPageContext {
219
+ /**
220
+ * The platform-native request — Express' `Request`, Fastify's `FastifyRequest`. Typed `unknown`
221
+ * for the same reason `SessionHook`'s is: this package refuses to depend on either platform.
222
+ * Cast it to whatever your app actually runs on.
223
+ */
224
+ request: unknown;
225
+ /**
226
+ * The platform-native response. The hook OWNS it: it must write AND end it (`res.render(...)`,
227
+ * `res.status(401).send(...)`, an Inertia render, ...). If the hook returns without writing, the
228
+ * library falls back to its own built-in page rather than leaving the request hanging.
229
+ */
230
+ response: unknown;
231
+ /** Where this console is mounted (e.g. `/ai-gateway`) — useful for a "back to the console" link. */
232
+ basePath: string;
233
+ }
234
+ /**
235
+ * Host-owned page for an unauthenticated navigation to the console.
236
+ *
237
+ * The built-in page cannot know who hosts the console, so it can only say "open this console from
238
+ * your application" in the abstract — it can't name the launcher, link to it, or look like the rest
239
+ * of the host's product. This hook hands the whole response to the host instead: render through
240
+ * your own template engine, your own Inertia page, whatever.
241
+ *
242
+ * Served at `<basePath>/auth/session-required`, which is where `DashboardAuthPageGuard` already
243
+ * redirects a denied Mode-A navigation today — this hook changes what that page CONTAINS, not the
244
+ * flow that reaches it.
245
+ *
246
+ * Deliberately NOT a replacement for Mode B's built-in login form. A host that wants its own login
247
+ * UI uses Mode A plus this hook, and posts to `<basePath>/auth/session` from its own page — the
248
+ * mint endpoint is the supported primitive for that.
249
+ *
250
+ * Fail-closed by construction: it only ever runs on a request that has ALREADY been denied. A hook
251
+ * that throws, or that returns without writing, falls back to the built-in page — it cannot let
252
+ * anyone in.
253
+ */
254
+ type UnauthenticatedPageHook = (context: UnauthenticatedPageContext) => void | Promise<void>;
216
255
  /** Which `dashboardAuth` hook(s) a resolved config was given — see `resolveDashboardAuth`. */
217
256
  type AuthMode = 'session' | 'login';
218
257
  /**
@@ -244,6 +283,9 @@ interface DashboardAuthOptions {
244
283
  /** Re-checks a live session on sliding renewal; see `RevalidateHook`. Not a mode — it cannot
245
284
  * mint a session, only revoke one already minted by `session`/`login`. */
246
285
  revalidate?: RevalidateHook;
286
+ /** Renders the host's own page for an unauthenticated navigation, in place of the built-in
287
+ * "open this console from your application" card; see `UnauthenticatedPageHook`. */
288
+ unauthenticatedPage?: UnauthenticatedPageHook;
247
289
  }
248
290
  /** Resolved, validated `dashboardAuth` config used by the guards/controller. */
249
291
  interface ResolvedDashboardAuth {
@@ -253,6 +295,7 @@ interface ResolvedDashboardAuth {
253
295
  session?: SessionHook;
254
296
  login?: LoginHook;
255
297
  revalidate?: RevalidateHook;
298
+ unauthenticatedPage?: UnauthenticatedPageHook;
256
299
  }
257
300
  /**
258
301
  * Validate + resolve `dashboardAuth`. Returns `null` when unconfigured (behavior unchanged — the
@@ -289,9 +332,10 @@ interface LoginFormBody {
289
332
  * hook decides — or Mode B (`login`), a dependency-free, server-rendered login form (GET/POST) —
290
333
  * the bundled React SPA in this package is a built Vite artifact with no auth-aware UI of its own,
291
334
  * so gating the actual page navigation needs a self-contained flow that doesn't touch it. Each
292
- * mode's routes 404 when that mode isn't configured (`auth.session`/`auth.login` absent), and
293
- * every route 404s outright when `dashboardAuth` is unconfigured (`DASHBOARD_AUTH` resolves to
294
- * `null`) — no dangling login page when the feature is off.
335
+ * mode's routes 404 when that mode isn't listed in `auth.modes` — the single source of truth for
336
+ * which mode(s) are configured, not hook-presence truthiness — and every route 404s outright when
337
+ * `dashboardAuth` is unconfigured (`DASHBOARD_AUTH` resolves to `null`) — no dangling login page
338
+ * when the feature is off.
295
339
  */
296
340
  declare class AgentDashboardAuthController {
297
341
  private readonly auth;
@@ -304,7 +348,7 @@ declare class AgentDashboardAuthController {
304
348
  login(body: LoginFormBody, req: unknown, res: AuthPageResponse): Promise<string>;
305
349
  logout(req: unknown, res: AuthPageResponse): string;
306
350
  session(request: unknown, response: unknown): Promise<void>;
307
- sessionRequiredPage(): string;
351
+ sessionRequiredPage(req: AuthPageRequest, res: unknown): Promise<void>;
308
352
  /** Uniform failure: same redirect (generic error flag) whether the user is unknown or the password is wrong. */
309
353
  private redirectToLoginError;
310
354
  private currentSession;
@@ -314,6 +358,11 @@ declare class AgentDashboardAuthController {
314
358
  * endpoint into a stack-trace leak nor floods the logs on repeated bad attempts.
315
359
  */
316
360
  private runHook;
361
+ /**
362
+ * Warn once per hook kind for the life of the process, not once per request: a broken hook fires
363
+ * on every denied navigation and would otherwise flood the logs.
364
+ */
365
+ private warnHookOnce;
317
366
  private requireAuth;
318
367
  }
319
368
 
@@ -587,4 +636,4 @@ declare const DASHBOARD_APPROVAL_ACTOR_REF: unique symbol;
587
636
  */
588
637
  declare const DASHBOARD_AUTH: unique symbol;
589
638
 
590
- export { AGENT_ACTOR_DIRECTORY, AGENT_ACTOR_RESOLVER, AGENT_APPROVAL_PORT, AGENT_GOVERNANCE_QUERIES, AGENT_PRICING_STORE, type ActorSpendRowWithLabel, AgentApiController, AgentApiModule, type AgentDashboardAsyncOptions, AgentDashboardAuthController, AgentDashboardModule, type AgentDashboardMountPathsOptions, type AgentDashboardOptions, AgentUiController, type AuthMode, DASHBOARD_API_PATH, DASHBOARD_APPROVAL_ACTOR_REF, DASHBOARD_AUTH, DASHBOARD_BASE_PATH, type DashboardAuthOptions, DashboardService, type LiveAgentEvent, type LoginHook, type ReliabilityOverview, type ResolvedDashboardAuth, type RevalidateHook, type SessionHook, type SpendOverview, type ThreadActivityRowWithLabel, type ThreadSpendRowWithLabel, type WithActorLabel, agentDashboardMountPaths, parsePriceInput, resolveDashboardAuth };
639
+ export { AGENT_ACTOR_DIRECTORY, AGENT_ACTOR_RESOLVER, AGENT_APPROVAL_PORT, AGENT_GOVERNANCE_QUERIES, AGENT_PRICING_STORE, type ActorSpendRowWithLabel, AgentApiController, AgentApiModule, type AgentDashboardAsyncOptions, AgentDashboardAuthController, AgentDashboardModule, type AgentDashboardMountPathsOptions, type AgentDashboardOptions, AgentUiController, type AuthMode, DASHBOARD_API_PATH, DASHBOARD_APPROVAL_ACTOR_REF, DASHBOARD_AUTH, DASHBOARD_BASE_PATH, type DashboardAuthOptions, DashboardService, type LiveAgentEvent, type LoginHook, type ReliabilityOverview, type ResolvedDashboardAuth, type RevalidateHook, type SessionHook, type SpendOverview, type ThreadActivityRowWithLabel, type ThreadSpendRowWithLabel, type UnauthenticatedPageContext, type UnauthenticatedPageHook, type WithActorLabel, agentDashboardMountPaths, parsePriceInput, resolveDashboardAuth };
@@ -203,16 +203,55 @@ type SessionHook = (request: unknown) => Promise<DashboardSessionUser | null> |
203
203
  type LoginHook = (username: string, password: string) => Promise<DashboardSessionUser | null> | DashboardSessionUser | null;
204
204
  /**
205
205
  * Re-checks a LIVE session when the cookie is slid forward. Runs at most once per `ttl/2` PER
206
- * COOKIE GENERATION, per in-flight request — the gate is on the inbound cookie's `iat`, so every
207
- * request still carrying a not-yet-renewed cookie invokes this hook, not just the first: a page
208
- * load that fires N parallel API calls can trigger up to N calls before the refreshed cookie lands
209
- * on the client. Bounded and cheap for typical console traffic, but not a per-session-lifetime
210
- * cap — a slow or expensive check should debounce/cache on its own. Return `false` to revoke (the
211
- * cookie is cleared and the request denied). Distinct from `session`: that hook reads the host's
212
- * auth off a fresh request, which a console XHR does not carry — this one receives the
213
- * already-minted session.
206
+ * COOKIE GENERATION — the gate is on the inbound cookie's `iat`, so every request still carrying a
207
+ * not-yet-renewed cookie would, on its own, invoke this hook again; `maybeRenewSession` de-dupes
208
+ * concurrent calls for the same session (`sub` + `iat`) into a single in-flight call, so a page
209
+ * load that fires N parallel API calls still costs exactly ONE host round-trip, not N. Still not a
210
+ * per-session-lifetime cap — this can run again every `ttl/2` as the session keeps sliding, so a
211
+ * slow or expensive check should still debounce/cache across renewal windows on its own. Return
212
+ * `false` to revoke (the cookie is cleared and the request denied). Distinct from `session`: that
213
+ * hook reads the host's auth off a fresh request, which a console XHR does not carry — this one
214
+ * receives the already-minted session.
214
215
  */
215
216
  type RevalidateHook = (session: DashboardSessionUser) => Promise<boolean> | boolean;
217
+ /** What `unauthenticatedPage` receives. An object (not positional args) so fields can be added later. */
218
+ interface UnauthenticatedPageContext {
219
+ /**
220
+ * The platform-native request — Express' `Request`, Fastify's `FastifyRequest`. Typed `unknown`
221
+ * for the same reason `SessionHook`'s is: this package refuses to depend on either platform.
222
+ * Cast it to whatever your app actually runs on.
223
+ */
224
+ request: unknown;
225
+ /**
226
+ * The platform-native response. The hook OWNS it: it must write AND end it (`res.render(...)`,
227
+ * `res.status(401).send(...)`, an Inertia render, ...). If the hook returns without writing, the
228
+ * library falls back to its own built-in page rather than leaving the request hanging.
229
+ */
230
+ response: unknown;
231
+ /** Where this console is mounted (e.g. `/ai-gateway`) — useful for a "back to the console" link. */
232
+ basePath: string;
233
+ }
234
+ /**
235
+ * Host-owned page for an unauthenticated navigation to the console.
236
+ *
237
+ * The built-in page cannot know who hosts the console, so it can only say "open this console from
238
+ * your application" in the abstract — it can't name the launcher, link to it, or look like the rest
239
+ * of the host's product. This hook hands the whole response to the host instead: render through
240
+ * your own template engine, your own Inertia page, whatever.
241
+ *
242
+ * Served at `<basePath>/auth/session-required`, which is where `DashboardAuthPageGuard` already
243
+ * redirects a denied Mode-A navigation today — this hook changes what that page CONTAINS, not the
244
+ * flow that reaches it.
245
+ *
246
+ * Deliberately NOT a replacement for Mode B's built-in login form. A host that wants its own login
247
+ * UI uses Mode A plus this hook, and posts to `<basePath>/auth/session` from its own page — the
248
+ * mint endpoint is the supported primitive for that.
249
+ *
250
+ * Fail-closed by construction: it only ever runs on a request that has ALREADY been denied. A hook
251
+ * that throws, or that returns without writing, falls back to the built-in page — it cannot let
252
+ * anyone in.
253
+ */
254
+ type UnauthenticatedPageHook = (context: UnauthenticatedPageContext) => void | Promise<void>;
216
255
  /** Which `dashboardAuth` hook(s) a resolved config was given — see `resolveDashboardAuth`. */
217
256
  type AuthMode = 'session' | 'login';
218
257
  /**
@@ -244,6 +283,9 @@ interface DashboardAuthOptions {
244
283
  /** Re-checks a live session on sliding renewal; see `RevalidateHook`. Not a mode — it cannot
245
284
  * mint a session, only revoke one already minted by `session`/`login`. */
246
285
  revalidate?: RevalidateHook;
286
+ /** Renders the host's own page for an unauthenticated navigation, in place of the built-in
287
+ * "open this console from your application" card; see `UnauthenticatedPageHook`. */
288
+ unauthenticatedPage?: UnauthenticatedPageHook;
247
289
  }
248
290
  /** Resolved, validated `dashboardAuth` config used by the guards/controller. */
249
291
  interface ResolvedDashboardAuth {
@@ -253,6 +295,7 @@ interface ResolvedDashboardAuth {
253
295
  session?: SessionHook;
254
296
  login?: LoginHook;
255
297
  revalidate?: RevalidateHook;
298
+ unauthenticatedPage?: UnauthenticatedPageHook;
256
299
  }
257
300
  /**
258
301
  * Validate + resolve `dashboardAuth`. Returns `null` when unconfigured (behavior unchanged — the
@@ -289,9 +332,10 @@ interface LoginFormBody {
289
332
  * hook decides — or Mode B (`login`), a dependency-free, server-rendered login form (GET/POST) —
290
333
  * the bundled React SPA in this package is a built Vite artifact with no auth-aware UI of its own,
291
334
  * so gating the actual page navigation needs a self-contained flow that doesn't touch it. Each
292
- * mode's routes 404 when that mode isn't configured (`auth.session`/`auth.login` absent), and
293
- * every route 404s outright when `dashboardAuth` is unconfigured (`DASHBOARD_AUTH` resolves to
294
- * `null`) — no dangling login page when the feature is off.
335
+ * mode's routes 404 when that mode isn't listed in `auth.modes` — the single source of truth for
336
+ * which mode(s) are configured, not hook-presence truthiness — and every route 404s outright when
337
+ * `dashboardAuth` is unconfigured (`DASHBOARD_AUTH` resolves to `null`) — no dangling login page
338
+ * when the feature is off.
295
339
  */
296
340
  declare class AgentDashboardAuthController {
297
341
  private readonly auth;
@@ -304,7 +348,7 @@ declare class AgentDashboardAuthController {
304
348
  login(body: LoginFormBody, req: unknown, res: AuthPageResponse): Promise<string>;
305
349
  logout(req: unknown, res: AuthPageResponse): string;
306
350
  session(request: unknown, response: unknown): Promise<void>;
307
- sessionRequiredPage(): string;
351
+ sessionRequiredPage(req: AuthPageRequest, res: unknown): Promise<void>;
308
352
  /** Uniform failure: same redirect (generic error flag) whether the user is unknown or the password is wrong. */
309
353
  private redirectToLoginError;
310
354
  private currentSession;
@@ -314,6 +358,11 @@ declare class AgentDashboardAuthController {
314
358
  * endpoint into a stack-trace leak nor floods the logs on repeated bad attempts.
315
359
  */
316
360
  private runHook;
361
+ /**
362
+ * Warn once per hook kind for the life of the process, not once per request: a broken hook fires
363
+ * on every denied navigation and would otherwise flood the logs.
364
+ */
365
+ private warnHookOnce;
317
366
  private requireAuth;
318
367
  }
319
368
 
@@ -587,4 +636,4 @@ declare const DASHBOARD_APPROVAL_ACTOR_REF: unique symbol;
587
636
  */
588
637
  declare const DASHBOARD_AUTH: unique symbol;
589
638
 
590
- export { AGENT_ACTOR_DIRECTORY, AGENT_ACTOR_RESOLVER, AGENT_APPROVAL_PORT, AGENT_GOVERNANCE_QUERIES, AGENT_PRICING_STORE, type ActorSpendRowWithLabel, AgentApiController, AgentApiModule, type AgentDashboardAsyncOptions, AgentDashboardAuthController, AgentDashboardModule, type AgentDashboardMountPathsOptions, type AgentDashboardOptions, AgentUiController, type AuthMode, DASHBOARD_API_PATH, DASHBOARD_APPROVAL_ACTOR_REF, DASHBOARD_AUTH, DASHBOARD_BASE_PATH, type DashboardAuthOptions, DashboardService, type LiveAgentEvent, type LoginHook, type ReliabilityOverview, type ResolvedDashboardAuth, type RevalidateHook, type SessionHook, type SpendOverview, type ThreadActivityRowWithLabel, type ThreadSpendRowWithLabel, type WithActorLabel, agentDashboardMountPaths, parsePriceInput, resolveDashboardAuth };
639
+ export { AGENT_ACTOR_DIRECTORY, AGENT_ACTOR_RESOLVER, AGENT_APPROVAL_PORT, AGENT_GOVERNANCE_QUERIES, AGENT_PRICING_STORE, type ActorSpendRowWithLabel, AgentApiController, AgentApiModule, type AgentDashboardAsyncOptions, AgentDashboardAuthController, AgentDashboardModule, type AgentDashboardMountPathsOptions, type AgentDashboardOptions, AgentUiController, type AuthMode, DASHBOARD_API_PATH, DASHBOARD_APPROVAL_ACTOR_REF, DASHBOARD_AUTH, DASHBOARD_BASE_PATH, type DashboardAuthOptions, DashboardService, type LiveAgentEvent, type LoginHook, type ReliabilityOverview, type ResolvedDashboardAuth, type RevalidateHook, type SessionHook, type SpendOverview, type ThreadActivityRowWithLabel, type ThreadSpendRowWithLabel, type UnauthenticatedPageContext, type UnauthenticatedPageHook, type WithActorLabel, agentDashboardMountPaths, parsePriceInput, resolveDashboardAuth };
@@ -147,6 +147,20 @@ function resolveRawResponse(response) {
147
147
  return null;
148
148
  }
149
149
  __name(resolveRawResponse, "resolveRawResponse");
150
+ function responseAlreadyWritten(response) {
151
+ const raw = resolveRawResponse(response);
152
+ return raw?.headersSent === true;
153
+ }
154
+ __name(responseAlreadyWritten, "responseAlreadyWritten");
155
+ function sendHtml(response, status, html) {
156
+ const raw = resolveRawResponse(response);
157
+ if (!raw) return;
158
+ raw.statusCode = status;
159
+ raw.setHeader("content-type", "text/html; charset=utf-8");
160
+ raw.setHeader("cache-control", "no-store, must-revalidate");
161
+ raw.end(html);
162
+ }
163
+ __name(sendHtml, "sendHtml");
150
164
  function sendRedirect(response, status, location) {
151
165
  const raw = resolveRawResponse(response);
152
166
  if (!raw) return;
@@ -261,6 +275,30 @@ function clearSessionCookie(context) {
261
275
  appendSetCookie(context.response, cookie);
262
276
  }
263
277
  __name(clearSessionCookie, "clearSessionCookie");
278
+ var inFlightRevalidations = /* @__PURE__ */ new Map();
279
+ function revalidationKey(session) {
280
+ return `${session.sub}:${session.iat}`;
281
+ }
282
+ __name(revalidationKey, "revalidationKey");
283
+ function getOrStartRevalidation(auth, session, user) {
284
+ const hook = auth.revalidate;
285
+ if (!hook) return Promise.resolve(true);
286
+ const key = revalidationKey(session);
287
+ const inFlight = inFlightRevalidations.get(key);
288
+ if (inFlight) return inFlight;
289
+ const outcome = (async () => {
290
+ try {
291
+ return await hook(user);
292
+ } catch {
293
+ return false;
294
+ }
295
+ })().finally(() => {
296
+ inFlightRevalidations.delete(key);
297
+ });
298
+ inFlightRevalidations.set(key, outcome);
299
+ return outcome;
300
+ }
301
+ __name(getOrStartRevalidation, "getOrStartRevalidation");
264
302
  async function maybeRenewSession(auth, session, request, response, now = Date.now()) {
265
303
  if (now - session.iat <= auth.ttlMs / 2) return true;
266
304
  const user = {
@@ -270,20 +308,13 @@ async function maybeRenewSession(auth, session, request, response, now = Date.no
270
308
  } : {},
271
309
  roles: session.roles
272
310
  };
273
- if (auth.revalidate) {
274
- let allowed;
275
- try {
276
- allowed = await auth.revalidate(user);
277
- } catch {
278
- allowed = false;
279
- }
280
- if (!allowed) {
281
- clearSessionCookie({
282
- request,
283
- response
284
- });
285
- return false;
286
- }
311
+ const allowed = await getOrStartRevalidation(auth, session, user);
312
+ if (!allowed) {
313
+ clearSessionCookie({
314
+ request,
315
+ response
316
+ });
317
+ return false;
287
318
  }
288
319
  issueSessionCookie(user, {
289
320
  auth,
@@ -1305,7 +1336,7 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1305
1336
  }
1306
1337
  loginPage(req, res, returnToQuery, error) {
1307
1338
  const auth = this.requireAuth();
1308
- if (!auth.login) throw new NotFoundException();
1339
+ if (!auth.modes.includes("login")) throw new NotFoundException();
1309
1340
  const returnTo = sanitizeReturnTo(this.basePath, returnToQuery);
1310
1341
  if (this.currentSession(req)) {
1311
1342
  res.status(302);
@@ -1320,7 +1351,7 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1320
1351
  }
1321
1352
  async login(body, req, res) {
1322
1353
  const auth = this.requireAuth();
1323
- if (!auth.login) throw new NotFoundException();
1354
+ if (!auth.modes.includes("login")) throw new NotFoundException();
1324
1355
  const returnTo = sanitizeReturnTo(this.basePath, body?.returnTo);
1325
1356
  const username = body?.username;
1326
1357
  const password = body?.password;
@@ -1348,14 +1379,14 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1348
1379
  response: res
1349
1380
  });
1350
1381
  res.status(302);
1351
- res.setHeader("Location", this.auth && !this.auth.login ? `${this.basePath}/auth/session-required` : `${this.basePath}/auth/login`);
1382
+ res.setHeader("Location", this.auth && !this.auth.modes.includes("login") ? `${this.basePath}/auth/session-required` : `${this.basePath}/auth/login`);
1352
1383
  return "";
1353
1384
  }
1354
1385
  // Mode A: the host frontend (carrying its own auth) POSTs here; the host hook validates the raw
1355
1386
  // request and returns the session user, or `null` to deny. No credential reaches this library.
1356
1387
  async session(request, response) {
1357
1388
  const auth = this.requireAuth();
1358
- if (!auth.session) throw new NotFoundException();
1389
+ if (!auth.modes.includes("session")) throw new NotFoundException();
1359
1390
  const user = await this.runHook("session", () => auth.session?.(request) ?? null);
1360
1391
  if (!user) throw new UnauthorizedException2();
1361
1392
  issueSessionCookie(user, {
@@ -1367,10 +1398,31 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1367
1398
  // Mode-A-only landing: the host mints the session, so there is nothing to submit here. Serves
1368
1399
  // as the redirect target `DashboardAuthPageGuard` bounces to instead of `/auth/login`, which
1369
1400
  // 404s under Mode A (see `loginPage` above).
1370
- sessionRequiredPage() {
1401
+ //
1402
+ // Non-passthrough `@Res()` (unlike its sibling routes) because `dashboardAuth.unauthenticatedPage`
1403
+ // hands the response to the HOST, which writes and ends it itself — there is no return value for
1404
+ // Nest to send, and letting Nest also send one would double-write. The `Content-Type` /
1405
+ // `Cache-Control` this route used to carry as decorators move into `sendHtml` for the built-in
1406
+ // path; the host owns its own headers on the hook path.
1407
+ async sessionRequiredPage(req, res) {
1371
1408
  const auth = this.requireAuth();
1372
- if (auth.login) throw new NotFoundException();
1373
- return renderSessionRequiredPage();
1409
+ if (auth.modes.includes("login")) throw new NotFoundException();
1410
+ const hook = auth.unauthenticatedPage;
1411
+ if (hook) {
1412
+ try {
1413
+ await hook({
1414
+ request: req,
1415
+ response: res,
1416
+ basePath: this.basePath
1417
+ });
1418
+ if (responseAlreadyWritten(res)) return;
1419
+ this.warnHookOnce("unauthenticatedPage", "dashboardAuth.unauthenticatedPage returned without writing a response; falling back to the built-in page. The hook must write AND end the response it is given.");
1420
+ } catch (error) {
1421
+ this.warnHookOnce("unauthenticatedPage", `dashboardAuth.unauthenticatedPage threw; falling back to the built-in page. ${String(error)}`);
1422
+ if (responseAlreadyWritten(res)) return;
1423
+ }
1424
+ }
1425
+ sendHtml(res, 200, renderSessionRequiredPage());
1374
1426
  }
1375
1427
  /** Uniform failure: same redirect (generic error flag) whether the user is unknown or the password is wrong. */
1376
1428
  redirectToLoginError(res, returnTo) {
@@ -1395,13 +1447,19 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1395
1447
  try {
1396
1448
  return await run() ?? null;
1397
1449
  } catch (error) {
1398
- if (!this.warnedHooks.has(kind)) {
1399
- this.warnedHooks.add(kind);
1400
- this.logger.warn(`AgentDashboardModule dashboardAuth ${kind} hook threw; treating as denial. ${error instanceof Error ? error.message : String(error)}`);
1401
- }
1450
+ this.warnHookOnce(kind, `AgentDashboardModule dashboardAuth ${kind} hook threw; treating as denial. ${error instanceof Error ? error.message : String(error)}`);
1402
1451
  return null;
1403
1452
  }
1404
1453
  }
1454
+ /**
1455
+ * Warn once per hook kind for the life of the process, not once per request: a broken hook fires
1456
+ * on every denied navigation and would otherwise flood the logs.
1457
+ */
1458
+ warnHookOnce(kind, message) {
1459
+ if (this.warnedHooks.has(kind)) return;
1460
+ this.warnedHooks.add(kind);
1461
+ this.logger.warn(message);
1462
+ }
1405
1463
  requireAuth() {
1406
1464
  if (!this.auth) throw new NotFoundException();
1407
1465
  return this.auth;
@@ -1470,11 +1528,14 @@ _ts_decorate4([
1470
1528
  ], AgentDashboardAuthController.prototype, "session", null);
1471
1529
  _ts_decorate4([
1472
1530
  Get2("session-required"),
1473
- Header("Content-Type", "text/html; charset=utf-8"),
1474
- Header("Cache-Control", "no-store, must-revalidate"),
1531
+ _ts_param4(0, Req2()),
1532
+ _ts_param4(1, Res()),
1475
1533
  _ts_metadata4("design:type", Function),
1476
- _ts_metadata4("design:paramtypes", []),
1477
- _ts_metadata4("design:returntype", String)
1534
+ _ts_metadata4("design:paramtypes", [
1535
+ typeof AuthPageRequest === "undefined" ? Object : AuthPageRequest,
1536
+ Object
1537
+ ]),
1538
+ _ts_metadata4("design:returntype", Promise)
1478
1539
  ], AgentDashboardAuthController.prototype, "sessionRequiredPage", null);
1479
1540
  AgentDashboardAuthController = _ts_decorate4([
1480
1541
  Controller2("auth"),
@@ -1615,7 +1676,7 @@ var DashboardAuthPageGuard = class {
1615
1676
  * asserting away) the nullability.
1616
1677
  */
1617
1678
  deny(auth, request) {
1618
- if (!auth.login) {
1679
+ if (!auth.modes.includes("login")) {
1619
1680
  throw new DashboardAuthRedirect(`${this.basePath}/auth/session-required`);
1620
1681
  }
1621
1682
  const returnTo = readOriginalUrl(request);
@@ -1804,6 +1865,9 @@ function resolveDashboardAuth(options) {
1804
1865
  if (options.revalidate !== void 0 && typeof options.revalidate !== "function") {
1805
1866
  throw new Error("AgentDashboardModule dashboardAuth: `revalidate` must be a function.");
1806
1867
  }
1868
+ if (options.unauthenticatedPage !== void 0 && typeof options.unauthenticatedPage !== "function") {
1869
+ throw new Error("AgentDashboardModule dashboardAuth: `unauthenticatedPage` must be a function.");
1870
+ }
1807
1871
  if (modes.length === 0) {
1808
1872
  throw new Error("AgentDashboardModule dashboardAuth: at least one of `session` (the host mints the session from its own auth) or `login` (the built-in login screen) is required \u2014 otherwise the cookie can never be minted. Failing closed.");
1809
1873
  }
@@ -1819,6 +1883,9 @@ function resolveDashboardAuth(options) {
1819
1883
  } : {},
1820
1884
  ...options.revalidate !== void 0 ? {
1821
1885
  revalidate: options.revalidate
1886
+ } : {},
1887
+ ...options.unauthenticatedPage !== void 0 ? {
1888
+ unauthenticatedPage: options.unauthenticatedPage
1822
1889
  } : {}
1823
1890
  };
1824
1891
  }