@dudousxd/nestjs-agent-dashboard 0.9.1 → 0.10.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.
package/README.md CHANGED
@@ -42,7 +42,7 @@ AgentDashboardModule.forRoot({
42
42
  basePath?: string; // where the SPA is served. Default '/ai-gateway'
43
43
  apiBasePath?: string; // where the JSON/SSE API is mounted. Default '<basePath>/api'
44
44
  guards?: Type<CanActivate>[]; // bring-your-own auth — see "Console auth" below
45
- dashboardAuth?: DashboardAuthOptions; // built-in login screen — see "Console auth" below
45
+ dashboardAuth?: DashboardAuthOptions; // signed-cookie gate (Mode A session / Mode B login) — see "Console auth" below
46
46
  imports?: DynamicModule['imports'];
47
47
  approvalActorRef?: (req) => string | undefined;
48
48
  });
@@ -56,7 +56,7 @@ The console has no auth of its own by default — pick one of three postures, or
56
56
  | ------- | ----------- | ----- |
57
57
  | **Open** (default) | Local dev only. Never mount this way in production. | Omit both `guards` and `dashboardAuth`. |
58
58
  | **`guards`** | You already have auth the console can reuse — an existing cookie-session guard, an SSO/OIDC session, a header your reverse proxy injects. | Pass guard classes; see below. |
59
- | **`dashboardAuth`** | You have no ready-made guard for the console — e.g. your app authenticates with a header-only Bearer token a browser navigation can't attach — and want the simplest path to a protected console with zero SSO setup. | Pass `{ secret, login }`; see below. |
59
+ | **`dashboardAuth`** | You have no ready-made guard for the console — e.g. your app authenticates with a header-only Bearer token a browser navigation can't attach — and want a protected console without building a whole `CanActivate`. Two modes: `session` (host mints the cookie from its own auth) or `login` (built-in login screen). | Pass `{ secret, session }` and/or `{ secret, login }`; see below. |
60
60
 
61
61
  Both `guards` and `dashboardAuth` can be set together — **AND semantics**: a request must pass BOTH. `dashboardAuth`'s own gate runs first; a denied request never reaches your `guards`.
62
62
 
@@ -90,9 +90,31 @@ class ConsoleSessionGuard implements CanActivate {
90
90
  export class AppModule {}
91
91
  ```
92
92
 
93
- ### Option B — `dashboardAuth` (built-in login screen)
93
+ ### Option B — `dashboardAuth` (signed session cookie)
94
94
 
95
- Gates the console behind a stateless, signed session cookie (HMAC-SHA256, `node:crypto` only — no JWT dependency, no session store, mirrors `@dudousxd/nestjs-telescope`'s `dashboardAuth`). An unauthenticated page visit is redirected (302) to a built-in login form at `<basePath>/auth/login`; an unauthenticated API call gets `401`.
95
+ Gates the console behind a stateless, signed session cookie (HMAC-SHA256, `node:crypto` only — no JWT dependency, no session store, mirrors `@dudousxd/nestjs-telescope`'s `dashboardAuth`). Two ways to mint that cookie — **Mode A** (`session`) and **Mode B** (`login`). At least one is required, or `forRoot`/`forRootAsync` throws at boot (an un-mintable gate is a boot error, not a silently-open console); you can also configure both together (e.g. Mode A for normal use, Mode B as a break-glass fallback). An unauthenticated API call always gets `401`, regardless of mode.
96
+
97
+ #### Mode A — `session` (the host mints the cookie)
98
+
99
+ Your host app already has its own auth (SSO/OIDC/whatever) — no need to teach this library a second credential. The host frontend, carrying that auth, `POST`s to `<basePath>/auth/session`; your `session` hook validates the raw request and returns the session user, or `null` to deny (`401`). No credential this library understands ever exists.
100
+
101
+ ```ts
102
+ AgentDashboardModule.forRoot({
103
+ dashboardAuth: {
104
+ secret: process.env.AI_GATEWAY_AUTH_SECRET, // REQUIRED — 32+ bytes recommended. Missing => boot error.
105
+ session: async (request) => {
106
+ const user = await sessions.verifyFromCookie(request); // read YOUR app's own session
107
+ return user ? { id: user.id, name: user.name, roles: user.roles } : null; // null => 401
108
+ },
109
+ },
110
+ });
111
+ ```
112
+
113
+ Have your host frontend call `POST <basePath>/auth/session` once it knows the visitor is signed in — no body this library reads. Success is `204` with the `Set-Cookie`; a denial is `401` and no cookie is set. An unauthenticated PAGE navigation under Mode-A-only has nowhere to redirect to (there's no login form) — it's served a small, static instruction page at `<basePath>/auth/session-required` instead, telling the visitor to sign in through the host app and reload.
114
+
115
+ #### Mode B — `login` (built-in login screen)
116
+
117
+ No host frontend/IdP to lean on — the console serves its own small, dependency-free, server-rendered login page. An unauthenticated page visit is redirected (302) to a built-in login form at `<basePath>/auth/login`.
96
118
 
97
119
  ```ts
98
120
  AgentDashboardModule.forRoot({
@@ -107,7 +129,7 @@ AgentDashboardModule.forRoot({
107
129
  });
108
130
  ```
109
131
 
110
- Open `/ai-gateway`, enter the credentials, and you're in. Bad credentials get a **uniform** redirect back to the login page — the response is identical for an unknown user and a wrong password, so the endpoint never reveals which one was wrong. `POST <basePath>/auth/logout` clears the cookie.
132
+ Open `/ai-gateway`, enter the credentials, and you're in. Bad credentials get a **uniform** redirect back to the login page — the response is identical for an unknown user and a wrong password, so the endpoint never reveals which one was wrong. `POST <basePath>/auth/logout` clears the cookie (redirecting back to the login form, or — Mode-A-only — to the `session-required` instruction page above).
111
133
 
112
134
  Only `username` is required (non-empty) — the password is passed through to `login` verbatim, including empty, so a host that authenticates by username/email alone (password deliberately ignored) can use the built-in screen unmodified; the hook owns whether an empty password is accepted or rejected.
113
135
 
@@ -129,6 +151,20 @@ AgentDashboardModule.forRootAsync({
129
151
  });
130
152
  ```
131
153
 
154
+ #### Session lifetime: `ttl`, sliding renewal, and `revalidate`
155
+
156
+ Any request carrying a valid cookie past 50% of its `ttl` gets a freshly re-signed one, so an active tab never expires mid-use. `revalidate?: (session) => boolean | Promise<boolean>` hooks into that same sliding-renewal path — a chance to re-check a *live* session (e.g. "is this user still active?") before the fresh cookie ships, so a deactivated or demoted operator loses access instead of riding a self-renewing cookie for as long as the tab stays open. Return `false` (or throw) to revoke: the cookie is cleared and the request denied, the same treatment as an absent cookie. It runs at most once per `ttl/2` per cookie generation, per in-flight request — concurrent requests still carrying the same not-yet-renewed cookie each invoke it (a page load firing several parallel API calls can trigger several calls before the refreshed cookie lands), so size it for occasional concurrent invocations, not a strict once-per-session cap. `revalidate` alone can't mint a session — it doesn't count toward the `session`/`login` "at least one" requirement.
157
+
158
+ ```ts
159
+ AgentDashboardModule.forRoot({
160
+ dashboardAuth: {
161
+ secret: process.env.AI_GATEWAY_AUTH_SECRET,
162
+ session: async (request) => /* ... */,
163
+ revalidate: async (session) => (await users.findActive(session.id)) !== null,
164
+ },
165
+ });
166
+ ```
167
+
132
168
  ## JSON + SSE API
133
169
 
134
170
  Mounted at `apiBasePath`:
@@ -303,6 +303,56 @@ function clearSessionCookie(context) {
303
303
  appendSetCookie(context.response, cookie);
304
304
  }
305
305
  __name(clearSessionCookie, "clearSessionCookie");
306
+ var inFlightRevalidations = /* @__PURE__ */ new Map();
307
+ function revalidationKey(session) {
308
+ return `${session.sub}:${session.iat}`;
309
+ }
310
+ __name(revalidationKey, "revalidationKey");
311
+ function getOrStartRevalidation(auth, session, user) {
312
+ const hook = auth.revalidate;
313
+ if (!hook) return Promise.resolve(true);
314
+ const key = revalidationKey(session);
315
+ const inFlight = inFlightRevalidations.get(key);
316
+ if (inFlight) return inFlight;
317
+ const outcome = (async () => {
318
+ try {
319
+ return await hook(user);
320
+ } catch {
321
+ return false;
322
+ }
323
+ })().finally(() => {
324
+ inFlightRevalidations.delete(key);
325
+ });
326
+ inFlightRevalidations.set(key, outcome);
327
+ return outcome;
328
+ }
329
+ __name(getOrStartRevalidation, "getOrStartRevalidation");
330
+ async function maybeRenewSession(auth, session, request, response, now = Date.now()) {
331
+ if (now - session.iat <= auth.ttlMs / 2) return true;
332
+ const user = {
333
+ id: session.sub,
334
+ ...session.name !== void 0 ? {
335
+ name: session.name
336
+ } : {},
337
+ roles: session.roles
338
+ };
339
+ const allowed = await getOrStartRevalidation(auth, session, user);
340
+ if (!allowed) {
341
+ clearSessionCookie({
342
+ request,
343
+ response
344
+ });
345
+ return false;
346
+ }
347
+ issueSessionCookie(user, {
348
+ auth,
349
+ request,
350
+ response,
351
+ now
352
+ });
353
+ return true;
354
+ }
355
+ __name(maybeRenewSession, "maybeRenewSession");
306
356
 
307
357
  // src/server/auth/dashboard-auth.guard.ts
308
358
  function _ts_decorate(decorators, target, key, desc) {
@@ -330,14 +380,16 @@ var DashboardAuthGuard = class {
330
380
  constructor(auth) {
331
381
  this.auth = auth;
332
382
  }
333
- canActivate(context) {
383
+ async canActivate(context) {
334
384
  if (!this.auth) return true;
335
385
  const http = context.switchToHttp();
336
386
  const request = http.getRequest();
337
387
  const session = this.verifyRequestSession(request);
338
388
  if (!session) throw new import_common.UnauthorizedException();
339
389
  attachSession(request, session);
340
- this.maybeRenew(http.getResponse(), request, session);
390
+ if (!await maybeRenewSession(this.auth, session, request, http.getResponse())) {
391
+ throw new import_common.UnauthorizedException();
392
+ }
341
393
  return true;
342
394
  }
343
395
  verifyRequestSession(request) {
@@ -349,28 +401,6 @@ var DashboardAuthGuard = class {
349
401
  secret: auth.secret
350
402
  });
351
403
  }
352
- /**
353
- * Sliding renewal: a valid cookie past 50% of its TTL is transparently re-issued on the
354
- * response, so an active user never gets logged out mid-session.
355
- */
356
- maybeRenew(response, request, session) {
357
- const auth = this.auth;
358
- if (!auth) return;
359
- const now = Date.now();
360
- if (now - session.iat <= auth.ttlMs / 2) return;
361
- issueSessionCookie({
362
- id: session.sub,
363
- ...session.name !== void 0 ? {
364
- name: session.name
365
- } : {},
366
- roles: session.roles
367
- }, {
368
- auth,
369
- request,
370
- response,
371
- now
372
- });
373
- }
374
404
  };
375
405
  DashboardAuthGuard = _ts_decorate([
376
406
  (0, import_common.Injectable)(),
@@ -1166,17 +1196,14 @@ function escapeHtml(value) {
1166
1196
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1167
1197
  }
1168
1198
  __name(escapeHtml, "escapeHtml");
1169
- function renderLoginPage(options) {
1170
- const action = escapeHtml(options.actionUrl);
1171
- const returnTo = escapeHtml(options.returnTo);
1172
- const errorBanner = options.error === true ? '<p role="alert" class="error">Invalid username or password.</p>' : "";
1199
+ function pageShell(content, title) {
1173
1200
  return `<!doctype html>
1174
1201
  <html lang="en">
1175
1202
  <head>
1176
1203
  <meta charset="utf-8" />
1177
1204
  <meta name="viewport" content="width=device-width, initial-scale=1" />
1178
1205
  <meta name="robots" content="noindex, nofollow" />
1179
- <title>Sign in \u2014 AI Gateway</title>
1206
+ <title>${title}</title>
1180
1207
  <style>
1181
1208
  :root { color-scheme: dark; }
1182
1209
  * { box-sizing: border-box; }
@@ -1207,6 +1234,13 @@ function renderLoginPage(options) {
1207
1234
  font-weight: 600;
1208
1235
  color: #34d399;
1209
1236
  }
1237
+ h1 {
1238
+ margin: 0 0 12px;
1239
+ font-size: 16px;
1240
+ font-weight: 600;
1241
+ color: #f4f4f5;
1242
+ }
1243
+ p { margin: 0 0 16px; color: #a1a1aa; }
1210
1244
  form { display: flex; flex-direction: column; gap: 16px; }
1211
1245
  label { display: flex; flex-direction: column; gap: 6px; }
1212
1246
  .field-label {
@@ -1245,7 +1279,17 @@ function renderLoginPage(options) {
1245
1279
  </head>
1246
1280
  <body>
1247
1281
  <div class="card">
1248
- <p class="brand">AI Gateway</p>
1282
+ ${content}
1283
+ </div>
1284
+ </body>
1285
+ </html>`;
1286
+ }
1287
+ __name(pageShell, "pageShell");
1288
+ function renderLoginPage(options) {
1289
+ const action = escapeHtml(options.actionUrl);
1290
+ const returnTo = escapeHtml(options.returnTo);
1291
+ const errorBanner = options.error === true ? '<p role="alert" class="error">Invalid username or password.</p>' : "";
1292
+ return pageShell(`<p class="brand">AI Gateway</p>
1249
1293
  <form method="post" action="${action}">
1250
1294
  <input type="hidden" name="returnTo" value="${returnTo}" />
1251
1295
  <label>
@@ -1258,12 +1302,15 @@ function renderLoginPage(options) {
1258
1302
  </label>
1259
1303
  ${errorBanner}
1260
1304
  <button type="submit">Sign in</button>
1261
- </form>
1262
- </div>
1263
- </body>
1264
- </html>`;
1305
+ </form>`, "Sign in \u2014 AI Gateway");
1265
1306
  }
1266
1307
  __name(renderLoginPage, "renderLoginPage");
1308
+ function renderSessionRequiredPage() {
1309
+ return pageShell(`<p class="brand">AI Gateway</p>
1310
+ <h1>Open this console from your application</h1>
1311
+ <p>Your session is minted by the host app. Use its console launcher to sign in, then reload.</p>`, "Sign in \u2014 AI Gateway");
1312
+ }
1313
+ __name(renderSessionRequiredPage, "renderSessionRequiredPage");
1267
1314
 
1268
1315
  // src/server/auth/sanitize-return-to.ts
1269
1316
  function sanitizeReturnTo(basePath, returnTo) {
@@ -1309,14 +1356,15 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1309
1356
  auth;
1310
1357
  basePath;
1311
1358
  logger = new import_common7.Logger(_AgentDashboardAuthController.name);
1312
- /** Warn once (not per request) so a broken hook can't be used to flood the logs. */
1313
- warnedLoginHookThrow = false;
1359
+ /** Warn once PER HOOK KIND (not per request) so a broken hook can't be used to flood the logs. */
1360
+ warnedHooks = /* @__PURE__ */ new Set();
1314
1361
  constructor(auth, basePath) {
1315
1362
  this.auth = auth;
1316
1363
  this.basePath = basePath;
1317
1364
  }
1318
1365
  loginPage(req, res, returnToQuery, error) {
1319
- this.requireAuth();
1366
+ const auth = this.requireAuth();
1367
+ if (!auth.modes.includes("login")) throw new import_common7.NotFoundException();
1320
1368
  const returnTo = sanitizeReturnTo(this.basePath, returnToQuery);
1321
1369
  if (this.currentSession(req)) {
1322
1370
  res.status(302);
@@ -1331,6 +1379,7 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1331
1379
  }
1332
1380
  async login(body, req, res) {
1333
1381
  const auth = this.requireAuth();
1382
+ if (!auth.modes.includes("login")) throw new import_common7.NotFoundException();
1334
1383
  const returnTo = sanitizeReturnTo(this.basePath, body?.returnTo);
1335
1384
  const username = body?.username;
1336
1385
  const password = body?.password;
@@ -1338,7 +1387,7 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1338
1387
  this.redirectToLoginError(res, returnTo);
1339
1388
  return "";
1340
1389
  }
1341
- const user = await this.runLoginHook(auth, username.trim(), isString(password) ? password : "");
1390
+ const user = await this.runHook("login", () => auth.login?.(username.trim(), isString(password) ? password : "") ?? null);
1342
1391
  if (!user) {
1343
1392
  this.redirectToLoginError(res, returnTo);
1344
1393
  return "";
@@ -1358,9 +1407,30 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1358
1407
  response: res
1359
1408
  });
1360
1409
  res.status(302);
1361
- res.setHeader("Location", `${this.basePath}/auth/login`);
1410
+ res.setHeader("Location", this.auth && !this.auth.modes.includes("login") ? `${this.basePath}/auth/session-required` : `${this.basePath}/auth/login`);
1362
1411
  return "";
1363
1412
  }
1413
+ // Mode A: the host frontend (carrying its own auth) POSTs here; the host hook validates the raw
1414
+ // request and returns the session user, or `null` to deny. No credential reaches this library.
1415
+ async session(request, response) {
1416
+ const auth = this.requireAuth();
1417
+ if (!auth.modes.includes("session")) throw new import_common7.NotFoundException();
1418
+ const user = await this.runHook("session", () => auth.session?.(request) ?? null);
1419
+ if (!user) throw new import_common7.UnauthorizedException();
1420
+ issueSessionCookie(user, {
1421
+ auth,
1422
+ request,
1423
+ response
1424
+ });
1425
+ }
1426
+ // Mode-A-only landing: the host mints the session, so there is nothing to submit here. Serves
1427
+ // as the redirect target `DashboardAuthPageGuard` bounces to instead of `/auth/login`, which
1428
+ // 404s under Mode A (see `loginPage` above).
1429
+ sessionRequiredPage() {
1430
+ const auth = this.requireAuth();
1431
+ if (auth.modes.includes("login")) throw new import_common7.NotFoundException();
1432
+ return renderSessionRequiredPage();
1433
+ }
1364
1434
  /** Uniform failure: same redirect (generic error flag) whether the user is unknown or the password is wrong. */
1365
1435
  redirectToLoginError(res, returnTo) {
1366
1436
  res.status(303);
@@ -1376,17 +1446,17 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1376
1446
  }) !== null;
1377
1447
  }
1378
1448
  /**
1379
- * Run the host's `login` hook defensively: a throw (sync or async) is treated as a denial
1380
- * (`null`) and warn-logged ONCE, so a buggy/unreachable hook never 500s the endpoint into a
1381
- * stack-trace leak nor floods the logs.
1449
+ * Run a host hook (`login` or `session`) defensively: a throw (sync or async) is treated as a
1450
+ * denial (`null`) and warn-logged ONCE PER KIND, so a buggy/unreachable hook never 500s the
1451
+ * endpoint into a stack-trace leak nor floods the logs on repeated bad attempts.
1382
1452
  */
1383
- async runLoginHook(auth, username, password) {
1453
+ async runHook(kind, run) {
1384
1454
  try {
1385
- return await auth.login(username, password) ?? null;
1455
+ return await run() ?? null;
1386
1456
  } catch (error) {
1387
- if (!this.warnedLoginHookThrow) {
1388
- this.warnedLoginHookThrow = true;
1389
- this.logger.warn(`AgentDashboardModule dashboardAuth login hook threw; treating as denial. ${error instanceof Error ? error.message : String(error)}`);
1457
+ if (!this.warnedHooks.has(kind)) {
1458
+ this.warnedHooks.add(kind);
1459
+ this.logger.warn(`AgentDashboardModule dashboardAuth ${kind} hook threw; treating as denial. ${error instanceof Error ? error.message : String(error)}`);
1390
1460
  }
1391
1461
  return null;
1392
1462
  }
@@ -1443,6 +1513,28 @@ _ts_decorate4([
1443
1513
  ]),
1444
1514
  _ts_metadata4("design:returntype", String)
1445
1515
  ], AgentDashboardAuthController.prototype, "logout", null);
1516
+ _ts_decorate4([
1517
+ (0, import_common7.Post)("session"),
1518
+ (0, import_common7.HttpCode)(204),
1519
+ _ts_param4(0, (0, import_common7.Req)()),
1520
+ _ts_param4(1, (0, import_common7.Res)({
1521
+ passthrough: true
1522
+ })),
1523
+ _ts_metadata4("design:type", Function),
1524
+ _ts_metadata4("design:paramtypes", [
1525
+ Object,
1526
+ Object
1527
+ ]),
1528
+ _ts_metadata4("design:returntype", Promise)
1529
+ ], AgentDashboardAuthController.prototype, "session", null);
1530
+ _ts_decorate4([
1531
+ (0, import_common7.Get)("session-required"),
1532
+ (0, import_common7.Header)("Content-Type", "text/html; charset=utf-8"),
1533
+ (0, import_common7.Header)("Cache-Control", "no-store, must-revalidate"),
1534
+ _ts_metadata4("design:type", Function),
1535
+ _ts_metadata4("design:paramtypes", []),
1536
+ _ts_metadata4("design:returntype", String)
1537
+ ], AgentDashboardAuthController.prototype, "sessionRequiredPage", null);
1446
1538
  AgentDashboardAuthController = _ts_decorate4([
1447
1539
  (0, import_common7.Controller)("auth"),
1448
1540
  _ts_param4(0, (0, import_common7.Optional)()),
@@ -1555,19 +1647,39 @@ var DashboardAuthPageGuard = class {
1555
1647
  this.auth = auth;
1556
1648
  this.basePath = basePath;
1557
1649
  }
1558
- canActivate(context) {
1650
+ async canActivate(context) {
1559
1651
  if (!this.auth) return true;
1652
+ const auth = this.auth;
1560
1653
  const http = context.switchToHttp();
1561
1654
  const request = http.getRequest();
1562
1655
  const session = this.verifyRequestSession(request);
1563
- if (!session) {
1564
- const returnTo = readOriginalUrl(request);
1565
- throw new DashboardAuthRedirect(`${this.basePath}/auth/login?returnTo=${encodeURIComponent(returnTo)}`);
1566
- }
1656
+ if (!session) this.deny(auth, request);
1567
1657
  attachSession(request, session);
1568
- this.maybeRenew(http.getResponse(), request, session);
1658
+ if (!await maybeRenewSession(auth, session, request, http.getResponse())) {
1659
+ this.deny(auth, request);
1660
+ }
1569
1661
  return true;
1570
1662
  }
1663
+ /**
1664
+ * No valid session: bounce the browser navigation onward. Under Mode B (`login` configured)
1665
+ * that's the built-in login screen, carrying `returnTo` back to the page that was requested.
1666
+ * Under Mode-A-only there is no login screen to redirect to — the host mints the session
1667
+ * itself — so this instead serves the instruction page rather than redirecting into a 404.
1668
+ * Pulled out of `canActivate` so both the "no session at all" and the "revalidate revoked a
1669
+ * renewable session" paths reuse the exact same mode-aware deny logic.
1670
+ *
1671
+ * Takes `auth` explicitly rather than reading `this.auth`: `canActivate`'s `!this.auth` early
1672
+ * return narrows the field within that method only — TS doesn't carry it across a method call —
1673
+ * so the caller passes the already-narrowed value instead of this method re-deriving (or
1674
+ * asserting away) the nullability.
1675
+ */
1676
+ deny(auth, request) {
1677
+ if (!auth.modes.includes("login")) {
1678
+ throw new DashboardAuthRedirect(`${this.basePath}/auth/session-required`);
1679
+ }
1680
+ const returnTo = readOriginalUrl(request);
1681
+ throw new DashboardAuthRedirect(`${this.basePath}/auth/login?returnTo=${encodeURIComponent(returnTo)}`);
1682
+ }
1571
1683
  verifyRequestSession(request) {
1572
1684
  const auth = this.auth;
1573
1685
  if (!auth) return null;
@@ -1577,28 +1689,6 @@ var DashboardAuthPageGuard = class {
1577
1689
  secret: auth.secret
1578
1690
  });
1579
1691
  }
1580
- /**
1581
- * Sliding renewal: a valid cookie past 50% of its TTL is transparently re-issued on the
1582
- * response, so an active user never gets logged out mid-session.
1583
- */
1584
- maybeRenew(response, request, session) {
1585
- const auth = this.auth;
1586
- if (!auth) return;
1587
- const now = Date.now();
1588
- if (now - session.iat <= auth.ttlMs / 2) return;
1589
- issueSessionCookie({
1590
- id: session.sub,
1591
- ...session.name !== void 0 ? {
1592
- name: session.name
1593
- } : {},
1594
- roles: session.roles
1595
- }, {
1596
- auth,
1597
- request,
1598
- response,
1599
- now
1600
- });
1601
- }
1602
1692
  };
1603
1693
  DashboardAuthPageGuard = _ts_decorate6([
1604
1694
  (0, import_common9.Injectable)(),
@@ -1757,13 +1847,38 @@ function resolveDashboardAuth(options) {
1757
1847
  if (typeof options.secret !== "string" || options.secret === "") {
1758
1848
  throw new Error("AgentDashboardModule dashboardAuth: `secret` is required and must be a non-empty string (HMAC-SHA256 signing key, 32+ bytes recommended). Failing closed.");
1759
1849
  }
1760
- if (typeof options.login !== "function") {
1761
- throw new Error("AgentDashboardModule dashboardAuth: `login` is required (validates submitted credentials for the built-in login screen). Failing closed.");
1850
+ const modes = [];
1851
+ if (options.session !== void 0) {
1852
+ if (typeof options.session !== "function") {
1853
+ throw new Error("AgentDashboardModule dashboardAuth: `session` must be a function.");
1854
+ }
1855
+ modes.push("session");
1856
+ }
1857
+ if (options.login !== void 0) {
1858
+ if (typeof options.login !== "function") {
1859
+ throw new Error("AgentDashboardModule dashboardAuth: `login` must be a function.");
1860
+ }
1861
+ modes.push("login");
1862
+ }
1863
+ if (options.revalidate !== void 0 && typeof options.revalidate !== "function") {
1864
+ throw new Error("AgentDashboardModule dashboardAuth: `revalidate` must be a function.");
1865
+ }
1866
+ if (modes.length === 0) {
1867
+ 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.");
1762
1868
  }
1763
1869
  return {
1764
1870
  secret: options.secret,
1765
1871
  ttlMs: durationToMs(options.ttl ?? DEFAULT_TTL),
1766
- login: options.login
1872
+ modes,
1873
+ ...options.session !== void 0 ? {
1874
+ session: options.session
1875
+ } : {},
1876
+ ...options.login !== void 0 ? {
1877
+ login: options.login
1878
+ } : {},
1879
+ ...options.revalidate !== void 0 ? {
1880
+ revalidate: options.revalidate
1881
+ } : {}
1767
1882
  };
1768
1883
  }
1769
1884
  __name(resolveDashboardAuth, "resolveDashboardAuth");