@dudousxd/nestjs-agent-dashboard 0.9.0 → 0.10.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.
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,9 @@ 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).
133
+
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.
111
135
 
112
136
  Need the `login` hook to reach a DB (e.g. an `EntityManager`)? Use `forRootAsync` — `basePath`/`apiBasePath`/`guards` stay static (needed at module-build time for routing), only the auth config is resolved through DI:
113
137
 
@@ -127,6 +151,20 @@ AgentDashboardModule.forRootAsync({
127
151
  });
128
152
  ```
129
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
+
130
168
  ## JSON + SSE API
131
169
 
132
170
  Mounted at `apiBasePath`:
@@ -303,6 +303,39 @@ function clearSessionCookie(context) {
303
303
  appendSetCookie(context.response, cookie);
304
304
  }
305
305
  __name(clearSessionCookie, "clearSessionCookie");
306
+ async function maybeRenewSession(auth, session, request, response, now = Date.now()) {
307
+ if (now - session.iat <= auth.ttlMs / 2) return true;
308
+ const user = {
309
+ id: session.sub,
310
+ ...session.name !== void 0 ? {
311
+ name: session.name
312
+ } : {},
313
+ roles: session.roles
314
+ };
315
+ if (auth.revalidate) {
316
+ let allowed;
317
+ try {
318
+ allowed = await auth.revalidate(user);
319
+ } catch {
320
+ allowed = false;
321
+ }
322
+ if (!allowed) {
323
+ clearSessionCookie({
324
+ request,
325
+ response
326
+ });
327
+ return false;
328
+ }
329
+ }
330
+ issueSessionCookie(user, {
331
+ auth,
332
+ request,
333
+ response,
334
+ now
335
+ });
336
+ return true;
337
+ }
338
+ __name(maybeRenewSession, "maybeRenewSession");
306
339
 
307
340
  // src/server/auth/dashboard-auth.guard.ts
308
341
  function _ts_decorate(decorators, target, key, desc) {
@@ -330,14 +363,16 @@ var DashboardAuthGuard = class {
330
363
  constructor(auth) {
331
364
  this.auth = auth;
332
365
  }
333
- canActivate(context) {
366
+ async canActivate(context) {
334
367
  if (!this.auth) return true;
335
368
  const http = context.switchToHttp();
336
369
  const request = http.getRequest();
337
370
  const session = this.verifyRequestSession(request);
338
371
  if (!session) throw new import_common.UnauthorizedException();
339
372
  attachSession(request, session);
340
- this.maybeRenew(http.getResponse(), request, session);
373
+ if (!await maybeRenewSession(this.auth, session, request, http.getResponse())) {
374
+ throw new import_common.UnauthorizedException();
375
+ }
341
376
  return true;
342
377
  }
343
378
  verifyRequestSession(request) {
@@ -349,28 +384,6 @@ var DashboardAuthGuard = class {
349
384
  secret: auth.secret
350
385
  });
351
386
  }
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
387
  };
375
388
  DashboardAuthGuard = _ts_decorate([
376
389
  (0, import_common.Injectable)(),
@@ -1166,17 +1179,14 @@ function escapeHtml(value) {
1166
1179
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1167
1180
  }
1168
1181
  __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>' : "";
1182
+ function pageShell(content, title) {
1173
1183
  return `<!doctype html>
1174
1184
  <html lang="en">
1175
1185
  <head>
1176
1186
  <meta charset="utf-8" />
1177
1187
  <meta name="viewport" content="width=device-width, initial-scale=1" />
1178
1188
  <meta name="robots" content="noindex, nofollow" />
1179
- <title>Sign in \u2014 AI Gateway</title>
1189
+ <title>${title}</title>
1180
1190
  <style>
1181
1191
  :root { color-scheme: dark; }
1182
1192
  * { box-sizing: border-box; }
@@ -1207,6 +1217,13 @@ function renderLoginPage(options) {
1207
1217
  font-weight: 600;
1208
1218
  color: #34d399;
1209
1219
  }
1220
+ h1 {
1221
+ margin: 0 0 12px;
1222
+ font-size: 16px;
1223
+ font-weight: 600;
1224
+ color: #f4f4f5;
1225
+ }
1226
+ p { margin: 0 0 16px; color: #a1a1aa; }
1210
1227
  form { display: flex; flex-direction: column; gap: 16px; }
1211
1228
  label { display: flex; flex-direction: column; gap: 6px; }
1212
1229
  .field-label {
@@ -1245,7 +1262,17 @@ function renderLoginPage(options) {
1245
1262
  </head>
1246
1263
  <body>
1247
1264
  <div class="card">
1248
- <p class="brand">AI Gateway</p>
1265
+ ${content}
1266
+ </div>
1267
+ </body>
1268
+ </html>`;
1269
+ }
1270
+ __name(pageShell, "pageShell");
1271
+ function renderLoginPage(options) {
1272
+ const action = escapeHtml(options.actionUrl);
1273
+ const returnTo = escapeHtml(options.returnTo);
1274
+ const errorBanner = options.error === true ? '<p role="alert" class="error">Invalid username or password.</p>' : "";
1275
+ return pageShell(`<p class="brand">AI Gateway</p>
1249
1276
  <form method="post" action="${action}">
1250
1277
  <input type="hidden" name="returnTo" value="${returnTo}" />
1251
1278
  <label>
@@ -1258,12 +1285,15 @@ function renderLoginPage(options) {
1258
1285
  </label>
1259
1286
  ${errorBanner}
1260
1287
  <button type="submit">Sign in</button>
1261
- </form>
1262
- </div>
1263
- </body>
1264
- </html>`;
1288
+ </form>`, "Sign in \u2014 AI Gateway");
1265
1289
  }
1266
1290
  __name(renderLoginPage, "renderLoginPage");
1291
+ function renderSessionRequiredPage() {
1292
+ return pageShell(`<p class="brand">AI Gateway</p>
1293
+ <h1>Open this console from your application</h1>
1294
+ <p>Your session is minted by the host app. Use its console launcher to sign in, then reload.</p>`, "Sign in \u2014 AI Gateway");
1295
+ }
1296
+ __name(renderSessionRequiredPage, "renderSessionRequiredPage");
1267
1297
 
1268
1298
  // src/server/auth/sanitize-return-to.ts
1269
1299
  function sanitizeReturnTo(basePath, returnTo) {
@@ -1295,9 +1325,13 @@ function _ts_param4(paramIndex, decorator) {
1295
1325
  }
1296
1326
  __name(_ts_param4, "_ts_param");
1297
1327
  function isNonEmptyString(value) {
1298
- return typeof value === "string" && value !== "";
1328
+ return typeof value === "string" && value.trim() !== "";
1299
1329
  }
1300
1330
  __name(isNonEmptyString, "isNonEmptyString");
1331
+ function isString(value) {
1332
+ return typeof value === "string";
1333
+ }
1334
+ __name(isString, "isString");
1301
1335
  var AgentDashboardAuthController = class _AgentDashboardAuthController {
1302
1336
  static {
1303
1337
  __name(this, "AgentDashboardAuthController");
@@ -1305,14 +1339,15 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1305
1339
  auth;
1306
1340
  basePath;
1307
1341
  logger = new import_common7.Logger(_AgentDashboardAuthController.name);
1308
- /** Warn once (not per request) so a broken hook can't be used to flood the logs. */
1309
- warnedLoginHookThrow = false;
1342
+ /** Warn once PER HOOK KIND (not per request) so a broken hook can't be used to flood the logs. */
1343
+ warnedHooks = /* @__PURE__ */ new Set();
1310
1344
  constructor(auth, basePath) {
1311
1345
  this.auth = auth;
1312
1346
  this.basePath = basePath;
1313
1347
  }
1314
1348
  loginPage(req, res, returnToQuery, error) {
1315
- this.requireAuth();
1349
+ const auth = this.requireAuth();
1350
+ if (!auth.login) throw new import_common7.NotFoundException();
1316
1351
  const returnTo = sanitizeReturnTo(this.basePath, returnToQuery);
1317
1352
  if (this.currentSession(req)) {
1318
1353
  res.status(302);
@@ -1327,14 +1362,15 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1327
1362
  }
1328
1363
  async login(body, req, res) {
1329
1364
  const auth = this.requireAuth();
1365
+ if (!auth.login) throw new import_common7.NotFoundException();
1330
1366
  const returnTo = sanitizeReturnTo(this.basePath, body?.returnTo);
1331
1367
  const username = body?.username;
1332
1368
  const password = body?.password;
1333
- if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
1369
+ if (!isNonEmptyString(username) || password !== void 0 && !isString(password)) {
1334
1370
  this.redirectToLoginError(res, returnTo);
1335
1371
  return "";
1336
1372
  }
1337
- const user = await this.runLoginHook(auth, username, password);
1373
+ const user = await this.runHook("login", () => auth.login?.(username.trim(), isString(password) ? password : "") ?? null);
1338
1374
  if (!user) {
1339
1375
  this.redirectToLoginError(res, returnTo);
1340
1376
  return "";
@@ -1354,9 +1390,30 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1354
1390
  response: res
1355
1391
  });
1356
1392
  res.status(302);
1357
- res.setHeader("Location", `${this.basePath}/auth/login`);
1393
+ res.setHeader("Location", this.auth && !this.auth.login ? `${this.basePath}/auth/session-required` : `${this.basePath}/auth/login`);
1358
1394
  return "";
1359
1395
  }
1396
+ // Mode A: the host frontend (carrying its own auth) POSTs here; the host hook validates the raw
1397
+ // request and returns the session user, or `null` to deny. No credential reaches this library.
1398
+ async session(request, response) {
1399
+ const auth = this.requireAuth();
1400
+ if (!auth.session) throw new import_common7.NotFoundException();
1401
+ const user = await this.runHook("session", () => auth.session?.(request) ?? null);
1402
+ if (!user) throw new import_common7.UnauthorizedException();
1403
+ issueSessionCookie(user, {
1404
+ auth,
1405
+ request,
1406
+ response
1407
+ });
1408
+ }
1409
+ // Mode-A-only landing: the host mints the session, so there is nothing to submit here. Serves
1410
+ // as the redirect target `DashboardAuthPageGuard` bounces to instead of `/auth/login`, which
1411
+ // 404s under Mode A (see `loginPage` above).
1412
+ sessionRequiredPage() {
1413
+ const auth = this.requireAuth();
1414
+ if (auth.login) throw new import_common7.NotFoundException();
1415
+ return renderSessionRequiredPage();
1416
+ }
1360
1417
  /** Uniform failure: same redirect (generic error flag) whether the user is unknown or the password is wrong. */
1361
1418
  redirectToLoginError(res, returnTo) {
1362
1419
  res.status(303);
@@ -1372,17 +1429,17 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1372
1429
  }) !== null;
1373
1430
  }
1374
1431
  /**
1375
- * Run the host's `login` hook defensively: a throw (sync or async) is treated as a denial
1376
- * (`null`) and warn-logged ONCE, so a buggy/unreachable hook never 500s the endpoint into a
1377
- * stack-trace leak nor floods the logs.
1432
+ * Run a host hook (`login` or `session`) defensively: a throw (sync or async) is treated as a
1433
+ * denial (`null`) and warn-logged ONCE PER KIND, so a buggy/unreachable hook never 500s the
1434
+ * endpoint into a stack-trace leak nor floods the logs on repeated bad attempts.
1378
1435
  */
1379
- async runLoginHook(auth, username, password) {
1436
+ async runHook(kind, run) {
1380
1437
  try {
1381
- return await auth.login(username, password) ?? null;
1438
+ return await run() ?? null;
1382
1439
  } catch (error) {
1383
- if (!this.warnedLoginHookThrow) {
1384
- this.warnedLoginHookThrow = true;
1385
- this.logger.warn(`AgentDashboardModule dashboardAuth login hook threw; treating as denial. ${error instanceof Error ? error.message : String(error)}`);
1440
+ if (!this.warnedHooks.has(kind)) {
1441
+ this.warnedHooks.add(kind);
1442
+ this.logger.warn(`AgentDashboardModule dashboardAuth ${kind} hook threw; treating as denial. ${error instanceof Error ? error.message : String(error)}`);
1386
1443
  }
1387
1444
  return null;
1388
1445
  }
@@ -1439,6 +1496,28 @@ _ts_decorate4([
1439
1496
  ]),
1440
1497
  _ts_metadata4("design:returntype", String)
1441
1498
  ], AgentDashboardAuthController.prototype, "logout", null);
1499
+ _ts_decorate4([
1500
+ (0, import_common7.Post)("session"),
1501
+ (0, import_common7.HttpCode)(204),
1502
+ _ts_param4(0, (0, import_common7.Req)()),
1503
+ _ts_param4(1, (0, import_common7.Res)({
1504
+ passthrough: true
1505
+ })),
1506
+ _ts_metadata4("design:type", Function),
1507
+ _ts_metadata4("design:paramtypes", [
1508
+ Object,
1509
+ Object
1510
+ ]),
1511
+ _ts_metadata4("design:returntype", Promise)
1512
+ ], AgentDashboardAuthController.prototype, "session", null);
1513
+ _ts_decorate4([
1514
+ (0, import_common7.Get)("session-required"),
1515
+ (0, import_common7.Header)("Content-Type", "text/html; charset=utf-8"),
1516
+ (0, import_common7.Header)("Cache-Control", "no-store, must-revalidate"),
1517
+ _ts_metadata4("design:type", Function),
1518
+ _ts_metadata4("design:paramtypes", []),
1519
+ _ts_metadata4("design:returntype", String)
1520
+ ], AgentDashboardAuthController.prototype, "sessionRequiredPage", null);
1442
1521
  AgentDashboardAuthController = _ts_decorate4([
1443
1522
  (0, import_common7.Controller)("auth"),
1444
1523
  _ts_param4(0, (0, import_common7.Optional)()),
@@ -1551,19 +1630,39 @@ var DashboardAuthPageGuard = class {
1551
1630
  this.auth = auth;
1552
1631
  this.basePath = basePath;
1553
1632
  }
1554
- canActivate(context) {
1633
+ async canActivate(context) {
1555
1634
  if (!this.auth) return true;
1635
+ const auth = this.auth;
1556
1636
  const http = context.switchToHttp();
1557
1637
  const request = http.getRequest();
1558
1638
  const session = this.verifyRequestSession(request);
1559
- if (!session) {
1560
- const returnTo = readOriginalUrl(request);
1561
- throw new DashboardAuthRedirect(`${this.basePath}/auth/login?returnTo=${encodeURIComponent(returnTo)}`);
1562
- }
1639
+ if (!session) this.deny(auth, request);
1563
1640
  attachSession(request, session);
1564
- this.maybeRenew(http.getResponse(), request, session);
1641
+ if (!await maybeRenewSession(auth, session, request, http.getResponse())) {
1642
+ this.deny(auth, request);
1643
+ }
1565
1644
  return true;
1566
1645
  }
1646
+ /**
1647
+ * No valid session: bounce the browser navigation onward. Under Mode B (`login` configured)
1648
+ * that's the built-in login screen, carrying `returnTo` back to the page that was requested.
1649
+ * Under Mode-A-only there is no login screen to redirect to — the host mints the session
1650
+ * itself — so this instead serves the instruction page rather than redirecting into a 404.
1651
+ * Pulled out of `canActivate` so both the "no session at all" and the "revalidate revoked a
1652
+ * renewable session" paths reuse the exact same mode-aware deny logic.
1653
+ *
1654
+ * Takes `auth` explicitly rather than reading `this.auth`: `canActivate`'s `!this.auth` early
1655
+ * return narrows the field within that method only — TS doesn't carry it across a method call —
1656
+ * so the caller passes the already-narrowed value instead of this method re-deriving (or
1657
+ * asserting away) the nullability.
1658
+ */
1659
+ deny(auth, request) {
1660
+ if (!auth.login) {
1661
+ throw new DashboardAuthRedirect(`${this.basePath}/auth/session-required`);
1662
+ }
1663
+ const returnTo = readOriginalUrl(request);
1664
+ throw new DashboardAuthRedirect(`${this.basePath}/auth/login?returnTo=${encodeURIComponent(returnTo)}`);
1665
+ }
1567
1666
  verifyRequestSession(request) {
1568
1667
  const auth = this.auth;
1569
1668
  if (!auth) return null;
@@ -1573,28 +1672,6 @@ var DashboardAuthPageGuard = class {
1573
1672
  secret: auth.secret
1574
1673
  });
1575
1674
  }
1576
- /**
1577
- * Sliding renewal: a valid cookie past 50% of its TTL is transparently re-issued on the
1578
- * response, so an active user never gets logged out mid-session.
1579
- */
1580
- maybeRenew(response, request, session) {
1581
- const auth = this.auth;
1582
- if (!auth) return;
1583
- const now = Date.now();
1584
- if (now - session.iat <= auth.ttlMs / 2) return;
1585
- issueSessionCookie({
1586
- id: session.sub,
1587
- ...session.name !== void 0 ? {
1588
- name: session.name
1589
- } : {},
1590
- roles: session.roles
1591
- }, {
1592
- auth,
1593
- request,
1594
- response,
1595
- now
1596
- });
1597
- }
1598
1675
  };
1599
1676
  DashboardAuthPageGuard = _ts_decorate6([
1600
1677
  (0, import_common9.Injectable)(),
@@ -1753,13 +1830,38 @@ function resolveDashboardAuth(options) {
1753
1830
  if (typeof options.secret !== "string" || options.secret === "") {
1754
1831
  throw new Error("AgentDashboardModule dashboardAuth: `secret` is required and must be a non-empty string (HMAC-SHA256 signing key, 32+ bytes recommended). Failing closed.");
1755
1832
  }
1756
- if (typeof options.login !== "function") {
1757
- throw new Error("AgentDashboardModule dashboardAuth: `login` is required (validates submitted credentials for the built-in login screen). Failing closed.");
1833
+ const modes = [];
1834
+ if (options.session !== void 0) {
1835
+ if (typeof options.session !== "function") {
1836
+ throw new Error("AgentDashboardModule dashboardAuth: `session` must be a function.");
1837
+ }
1838
+ modes.push("session");
1839
+ }
1840
+ if (options.login !== void 0) {
1841
+ if (typeof options.login !== "function") {
1842
+ throw new Error("AgentDashboardModule dashboardAuth: `login` must be a function.");
1843
+ }
1844
+ modes.push("login");
1845
+ }
1846
+ if (options.revalidate !== void 0 && typeof options.revalidate !== "function") {
1847
+ throw new Error("AgentDashboardModule dashboardAuth: `revalidate` must be a function.");
1848
+ }
1849
+ if (modes.length === 0) {
1850
+ 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.");
1758
1851
  }
1759
1852
  return {
1760
1853
  secret: options.secret,
1761
1854
  ttlMs: durationToMs(options.ttl ?? DEFAULT_TTL),
1762
- login: options.login
1855
+ modes,
1856
+ ...options.session !== void 0 ? {
1857
+ session: options.session
1858
+ } : {},
1859
+ ...options.login !== void 0 ? {
1860
+ login: options.login
1861
+ } : {},
1862
+ ...options.revalidate !== void 0 ? {
1863
+ revalidate: options.revalidate
1864
+ } : {}
1763
1865
  };
1764
1866
  }
1765
1867
  __name(resolveDashboardAuth, "resolveDashboardAuth");